Mobile textfield troubleshooting and remove Web Speech

Web Speech had all kinds of bugs, not worth troubleshooting.
This commit is contained in:
WiseGuru 2026-05-27 20:24:33 -07:00
parent c207d5444d
commit dddfb0cf12
24 changed files with 686 additions and 356 deletions

5
.gitignore vendored
View file

@ -18,6 +18,11 @@ main.js
# obsidian
data.json
# Secrets: API keys live in secrets.json.nosync (any encryption mode).
# The .nosync suffix is for iCloud; git needs its own ignore rule.
secrets.json*
*.nosync
# Exclude macOS Finder (System Explorer) View States
.DS_Store

View file

@ -45,11 +45,10 @@ src/
├── main.ts # Lifecycle, commands, ribbon, settings tab registration
├── types.ts # Shared interfaces (provider IDs, configs, templates, settings)
├── http.ts # requestUrl wrappers: jsonPost/jsonGet/multipartPost + ProviderError
├── platform.ts # Active-profile resolver + MediaRecorder/Web Speech availability probes
├── platform.ts # Active-profile resolver + MediaRecorder availability probe
├── secrets.ts # safeStorage (desktop) + plaintext fallback (mobile) for API keys
├── recorder.ts # MediaRecorder state machine + getBestMimeType (no size cap; per-provider limits live in transcription/limits.ts)
├── audio-transcode.ts # WebAudio decode + resample to 16 kHz mono PCM WAV (shared by whisper-local and mistral-voxtral)
├── webspeech.ts # SpeechRecognition wrapper
├── pipeline.ts # transcribe → cleanup → insert orchestrator
├── insert.ts # cursor/newFile/append + {{date}}/{{time}} expansion
├── whisper-host.ts # Spawns/stops a user-supplied whisper-server child process (desktop only)
@ -77,7 +76,6 @@ src/
│ ├── deepgram.ts # single POST
│ ├── revai.ts # submit → poll → fetch text
│ ├── mistral-voxtral.ts # Mistral Voxtral STT (JSON response; always transcodes to WAV)
│ ├── webspeech.ts # adapter; throws "should not be called" (see Gotchas)
│ └── whisper-local.ts # Thin shim that POSTs to the WhisperHost-managed local server
└── llm/
├── index.ts # LLMProvider interface + createLLMProvider()
@ -91,21 +89,21 @@ src/
[src/pipeline.ts](src/pipeline.ts) runs four stages with `onStage` callbacks for UI:
1. **Persist audio** (`audio` source only): writes the raw `Blob` to the vault via [src/audio-persist.ts](src/audio-persist.ts) before transcription, so the user keeps the recording even if later stages fail. Path resolution: when `settings.attachmentsFolderPath` is set, the file goes under that folder with manual de-collision (`-1`, `-2`, ...); when empty, the path comes from `app.fileManager.getAvailablePathForAttachment(filename)`, which respects Obsidian's own attachments setting. Filename is `ReWrite-YYYY-MM-DD-HHmmss.<ext>` with the extension derived from the blob's mime type (`webm` / `m4a` / `ogg` / `wav` / `mp3`, default `webm`). Failure is non-fatal: a Notice fires and transcription proceeds. The resolved path is later prepended to the cleaned output as `![[<path>]]\n\n` before insertion.
2. **Transcribe**: `audio``createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config)`. Skipped when the source is `paste` or `text` (input passes through). Short-circuited when the source is `webspeech` (the transcript was already captured live by `src/webspeech.ts` during recording). Just before dispatching, `validateRecording(blobSize, durationMs, providerId)` from [src/transcription/limits.ts](src/transcription/limits.ts) throws a friendly per-provider error if the recording exceeds the provider's documented byte or duration cap. Because validation runs after `persist-audio`, the user keeps the saved file and can switch providers + reprocess from the vault.
2. **Transcribe**: `audio``createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config)`. Skipped when the source is `paste` or `text` (input passes through). Just before dispatching, `validateRecording(blobSize, durationMs, providerId)` from [src/transcription/limits.ts](src/transcription/limits.ts) throws a friendly per-provider error if the recording exceeds the provider's documented byte or duration cap. Because validation runs after `persist-audio`, the user keeps the saved file and can switch providers + reprocess from the vault.
3. **Cleanup**: `createLLMProvider(profile.llmProvider).complete(systemPrompt, transcript, config)`. The system prompt is the template prompt, optionally augmented with an `## Ad-hoc instructions` block when the wake-name scan ([src/wake-name.ts](src/wake-name.ts)) extracts directives from the transcript, and a `## Known nouns` block when `plugin.knownNouns` is non-empty (see Assistant prompt and Known nouns sections below). On error, the (possibly stripped) transcript is copied to the clipboard before re-throwing, so the user keeps their words.
4. **Insert**: `src/insert.ts` routes to `cursor` / `newFile` / `append` per the template. `cursor` falls back to `append` when no editor is active; `append` falls back to `newFile` when no markdown file exists. `{{date}}` / `{{time}}` in filename templates expand via Obsidian's `moment`. The modal's per-invocation Destination control overrides `insertMode` / `newFileFolder` / `newFileNameTemplate` via `PipelineParams.destinationOverride`; the override is shallow-merged onto a copy of the template before the insert call, so the template file on disk is never mutated.
The pipeline accepts an `AbortSignal` (forwarded to providers) and is consumed by [src/ui/modal.ts](src/ui/modal.ts), [src/ui/quick-record.ts](src/ui/quick-record.ts), and [src/ui/text-source.ts](src/ui/text-source.ts) (the `runTextPipeline` helper for command + editor-menu entry points). Every caller passes the plugin itself as `host: PipelineHost`; `PipelineHost` is a narrow interface ({ `assistantPrompt`, `knownNouns` }) so `cleanupTranscript` can read the loaded vault content without importing `ReWritePlugin` (which would form a circular dep through the UI layer).
The `PipelineSource` union has four variants: `audio` (recorded blob, optional `sourcePath` for reprocess flows), `paste` (textarea input), `webspeech` (live-captured transcript), `text` (input from an existing note via selection or whole body). Text-source flows skip transcription entirely and only require the LLM half of the profile. The `audio` variant's `sourcePath` is set by the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) to point at an existing vault file; when present, the persist stage is skipped and that path is reused for the `![[<path>]]\n\n` prepend.
The `PipelineSource` union has three variants: `audio` (recorded blob, optional `sourcePath` for reprocess flows), `paste` (textarea input), `text` (input from an existing note via selection or whole body). Text-source flows skip transcription entirely and only require the LLM half of the profile. The `audio` variant's `sourcePath` is set by the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) to point at an existing vault file; when present, the persist stage is skipped and that path is reused for the `![[<path>]]\n\n` prepend.
## Provider system
[src/transcription/index.ts](src/transcription/index.ts) and [src/llm/index.ts](src/llm/index.ts) each define a small interface plus a `create...Provider(id)` factory. Provider families share one adapter file where the API shapes match: OpenAI Whisper, `openai-compatible`, and Groq all dispatch into [src/transcription/openai.ts](src/transcription/openai.ts) with different base URLs; OpenAI GPT, `openai-compatible`, and Mistral all dispatch into [src/llm/openai.ts](src/llm/openai.ts). Mistral Voxtral does NOT share with `openai.ts` — see [src/transcription/mistral-voxtral.ts](src/transcription/mistral-voxtral.ts) — because Voxtral's response is JSON-only (no `response_format=text`), it rejects WebM input (so the blob is always transcoded to 16 kHz mono WAV via [src/audio-transcode.ts](src/audio-transcode.ts)), and `/v1/models` doesn't surface audio-model IDs the same way it surfaces chat models.
[src/transcription/index.ts](src/transcription/index.ts) and [src/llm/index.ts](src/llm/index.ts) each define a small interface plus a `create...Provider(id)` factory. Provider families share one adapter file where the API shapes match: OpenAI Whisper, `openai-compatible`, and Groq all dispatch into [src/transcription/openai.ts](src/transcription/openai.ts) with different base URLs; OpenAI GPT, `openai-compatible`, and Mistral all dispatch into [src/llm/openai.ts](src/llm/openai.ts). Mistral Voxtral does NOT share with `openai.ts` (see [src/transcription/mistral-voxtral.ts](src/transcription/mistral-voxtral.ts)) because Voxtral's response is JSON-only (no `response_format=text`) and it rejects WebM input (so the blob is always transcoded to 16 kHz mono WAV via [src/audio-transcode.ts](src/audio-transcode.ts)).
API keys are stored per profile on `EnvironmentProfile.transcriptionConfig.apiKey` / `llmConfig.apiKey`. Two slots per profile, one for transcription and one for the LLM. No global by-family map; the desktop and mobile profiles each carry their own keys even when both use the same provider (deliberate: per-profile keys make per-function usage tracking easier). Persistence is in [src/secrets.ts](src/secrets.ts) using the key IDs `profile:desktop:transcription`, `profile:desktop:llm`, `profile:mobile:transcription`, `profile:mobile:llm`.
Providers may optionally implement `listModels(config, signal)` returning a string array of model IDs the configured API key can access. Implemented by: OpenAI / Groq / Mistral (via `openai.ts` shared adapter), Anthropic, Gemini, Deepgram. Not implemented for `openai-compatible` (URL-specific, list-shape varies), AssemblyAI, Rev.ai, Web Speech. The settings tab caches results to `GlobalSettings.modelCache` per side and provider ID; the Refresh button in the model field triggers `listModels` and updates the cache. The text field next to the dropdown is always the canonical source of `profile.config.model` so users can type any string the dropdown doesn't expose.
Providers may optionally implement `listModels(config, signal)` returning a string array of model IDs the configured API key can access. Implemented by: OpenAI / Groq / Mistral (via `openai.ts` shared adapter), Mistral Voxtral (own adapter, filters Mistral's `/v1/models` catalog by ID substring `voxtral`), Anthropic, Gemini, Deepgram. Not implemented for `openai-compatible` (URL-specific, list-shape varies), AssemblyAI, Rev.ai. The settings tab caches results to `GlobalSettings.modelCache` per side and provider ID; the Refresh button in the model field triggers `listModels` and updates the cache. The text field next to the dropdown is always the canonical source of `profile.config.model` so users can type any string the dropdown doesn't expose.
## Local whisper.cpp host (desktop)
@ -217,12 +215,13 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma
- **`saveManyKeys` is a silent no-op when locked.** When `mode === 'passphrase'` and `unlockedKey === null`, `saveManyKeys` does nothing. This is deliberate: unrelated `plugin.saveSettings()` calls (e.g. user changes a model dropdown) would otherwise persist empty `apiKey` values for every profile, wiping the on-disk encrypted bag. The UI prevents this by disabling key fields and gating all pipeline entry points on `encryptionStatus.locked`. `saveKey` (single-key write) still throws so callers can react.
- **No baked-in model defaults.** Both profiles ship with `model: ""`. The modal renders an inline setup card that blocks recording/paste until the active profile has a provider, model, key, and (for `openai-compatible`) base URL. If you add a provider, do not bake a default model string; surface it as placeholder hint text.
- **`openai-compatible` base URL asymmetry** (literal interpretation of the spec): transcription appends `/v1/audio/transcriptions` to a *root* URL (`http://localhost:8080`); LLM appends `/chat/completions` to a URL that *already includes* `/v1` (`http://localhost:11434/v1`). The settings UI hint text and setup card both guide users; do not "normalize" one to match the other.
- **Web Speech adapter throws "should not be called".** The pipeline short-circuits when `source.kind === 'webspeech'` and uses the transcript captured live by `src/webspeech.ts` during recording. The factory still has the case to keep the switch exhaustive. On mobile WKWebView (iOS) `SpeechRecognition` is undefined, so [src/platform.ts](src/platform.ts) exports `isWebSpeechAvailable()`; the modal/setup-card and Quick Record both check it before starting a Web Speech session.
- **`setHeading()` instead of manual `<h2>`** inside settings tabs. `obsidianmd/settings-tab/no-manual-html-headings` forbids manual headings. Same applies anywhere else inside a settings tab that needs a section header.
- **`window.confirm` is banned** by ESLint's `no-alert`. If a future phase needs an in-vault confirmation prompt, add a small `Modal` subclass rather than reaching for `window.confirm`.
- **Sentence-case lint covers brand and acronym lists** in [eslint.config.mts](eslint.config.mts). Adding a new provider, model family, or product name means adding it to `REWRITE_BRANDS` (or `REWRITE_ACRONYMS` for things like `LLM`). Dropdown option labels also pass through the rule; in [src/settings/tab.ts](src/settings/tab.ts) and [src/ui/setup-card.ts](src/ui/setup-card.ts), labels are iterated via `opt.label` (member access) to dodge the literal-string check.
- **Provider option arrays appear in both setup-card.ts and tab.ts.** Intentionally duplicated rather than extracted, per the "don't refactor beyond what the task requires" rule. If the lists drift, fix the user-visible inconsistency, not the duplication.
- **Settings tab re-renders the entire container on dropdown changes** that toggle conditional fields (provider, insertMode, activeProfileOverride). Text fields call `saveSettings()` on change but do not redraw, so focus is preserved while typing. Preserve this pattern when adding new conditional fields.
- **Profile sections wrap their settings in `.rewrite-profile-section`.** [src/settings/tab.ts](src/settings/tab.ts) `renderProfile()` creates a wrapper div per profile rather than rendering settings as direct children of `containerEl`. The active-on-this-device profile (per `detectActiveProfileKind`) gets `is-active-profile` (accent left border) and a `.rewrite-profile-active-badge` span inside the heading's `nameEl`. The inactive profile's body is wrapped in a `<details class="rewrite-profile-collapsed">` whose expand state lives on `ReWriteSettingTab.inactiveProfileExpanded` so it survives the full-container redraws triggered by dropdowns. New per-profile settings must take `body` as their parent (the wrapper or the `<details>`), not the original `parent` arg, or they will render outside the section's visual frame.
- **Both provider unions include `'none'`.** [src/types.ts](src/types.ts) `TranscriptionProviderID` and `LLMProviderID` carry a `'none'` member for users who only want one half of the pipeline. The factories in [src/transcription/index.ts](src/transcription/index.ts) and [src/llm/index.ts](src/llm/index.ts) return sentinel providers (transcription throws on `transcribe()`; LLM `complete()` returns the user message unchanged), but the pipeline never actually calls these because: (a) `collectTranscript` throws a friendlier error when `transcriptionProvider === 'none'` and `source.kind === 'audio'`; (b) `cleanupTranscript` short-circuits and returns the raw transcript when `llmProvider === 'none'` (this also skips wake-name extraction and known-nouns injection, since both only matter when an LLM consumes the system prompt). The settings tab + setup card hide model/baseUrl/apiKey fields for the `'none'` side; `isProfileConfigured` / `isProfileConfiguredForText` treat `'none'` as configured. The modal's Record tab, Quick Record, and the reprocess-audio command all gate on `transcriptionProvider === 'none'` with a "use Paste instead" hint.
- **Templates are vault files, not settings.** There is no `settings.templates` array. Consumers read `plugin.templates` (refreshed from disk). When you add a field to `NoteTemplate`, update [src/templates-folder.ts](src/templates-folder.ts) on both sides: `parseTemplateFile` reads it out of frontmatter (with a sensible default if missing), and `renderTemplateFile` writes it into the frontmatter the populate button emits. The populate button is non-destructive: it skips files whose `id` already exists, so re-running it tops up the folder without clobbering user edits.
- **Frontmatter parsing uses `parseYaml` from Obsidian, not the metadata cache.** The metadata cache is async and may not be populated for newly created files; reading content via `app.vault.read(file)`, splitting off the leading `---...---` block, and parsing it with `parseYaml` is synchronous-enough and works immediately after `app.vault.create`.
- **Quick Record uses a custom floating div, not a `Notice`.** Obsidian `Notice` does not support real interactive buttons. The floater is a `position: fixed` div on `document.body`, owned by `QuickRecordController`, with `cancel()` wired into `onunload`.
@ -242,6 +241,7 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma
- **Destination override does not mutate the template object.** [src/ui/modal.ts](src/ui/modal.ts) renders a per-invocation Destination control (insertMode + conditional newFile fields) and threads the result through `PipelineParams.destinationOverride`. [src/pipeline.ts](src/pipeline.ts) shallow-merges the override onto a *copy* of the template via `applyDestinationOverride` before calling `insertOutput`; the cached template and the file on disk remain untouched. The override is ephemeral: it resets when the modal closes and when the template selector changes. Not exposed in Quick Record, `runTextPipeline`, or `runAudioFilePipeline` (no UI surface). The UI is a collapsible `<details>` whose `<summary>` reads `"Destination: Default (<description>)"` (no override) or `"Destination: Custom (<description>)"` (override set, forced open); expand state is tracked on `ReWriteModal.destinationExpanded` so it survives the full-container re-renders that fire when the inner insertMode dropdown changes. `describeDestination(mode, folder, name)` formats the description (e.g. `New file: ReWrite Notes/{{date}}-note`).
- **Quick Record floater holds its own popover.** The floater grew a third button between the timer and Stop that opens a popover-style template list ([src/ui/quick-record.ts](src/ui/quick-record.ts)). The popover is a child of the floater div, listens for outside-click via a capture-phase `document` listener and Escape via `document keydown`, and cleans up both listeners on dismiss. The popover dismisses on selection, Escape, outside click, and when `setBusy` runs (the pipeline is in flight). Selecting a template updates `controller.template` but does NOT update `lastUsedTemplateId`; that only happens after a successful completion, matching pre-popover behavior.
- **Whisper status bar polls `whisperHost.status()` every 1 s** via `registerInterval`. The host has no event emitter; both [src/settings/tab.ts](src/settings/tab.ts) (re-render on Start/Stop click) and [src/ui/whisper-status-bar.ts](src/ui/whisper-status-bar.ts) (interval poll) re-read the status synchronously. If you add a third consumer, poll the same way; do not bolt on an event system.
- **Mobile keyboard scroll uses `visualViewport`, not `scrollIntoView` alone.** Android WebViews don't resize the layout viewport when the soft keyboard opens, so a bare `scrollIntoView({ block: 'center' })` centers the input on the full screen, which is then covered by the keyboard. `installMobileKeyboardScrollFix` in [src/platform.ts](src/platform.ts) waits for `window.visualViewport.resize` (keyboard finished opening), then runs a two-step recovery: first scroll the nearest scrollable ancestor by the delta needed to lift the input (this is what works on the settings tab); if the input is still below the visible region, shrink the enclosing `.modal-container` to the visible region by setting inline `top` and `height`. Obsidian's flex centering inside `.modal-container` then re-positions the `.modal` popup above the keyboard. Scrolling alone cannot move a `position: fixed` popup. The container shrink restores on `blur` and re-fits on subsequent `visualViewport.resize` events (keyboard height changes, emoji panel, etc.); it fully restores when `vv.height` returns to near `window.innerHeight`. Falls back to the legacy `scrollIntoView` path when `visualViewport` is unavailable. Attach it on every container that hosts user-facing text inputs ([src/ui/modal.ts](src/ui/modal.ts), [src/settings/tab.ts](src/settings/tab.ts), [src/ui/passphrase-modal.ts](src/ui/passphrase-modal.ts), `RenamePromptModal` in [src/insert.ts](src/insert.ts)); `focusin` bubbles, so a single listener on the container covers descendants and the inline setup card.
## Local install for testing

View file

@ -7,7 +7,7 @@ You bring your own provider keys. Nothing is sent to a ReWrite server; the plugi
## Features
- Record audio directly in Obsidian, or paste a pre-existing transcript.
- 7 transcription providers: OpenAI Whisper, OpenAI-compatible (whisper.cpp, faster-whisper-server, etc.), Groq, AssemblyAI, Deepgram, Rev.ai, the browser-native Web Speech API, and a plugin-managed local whisper.cpp server (desktop).
- 7 transcription providers: OpenAI Whisper, OpenAI-compatible (whisper.cpp, faster-whisper-server, etc.), Groq, AssemblyAI, Deepgram, Rev.ai, Mistral Voxtral, and a plugin-managed local whisper.cpp server (desktop).
- 5 LLM providers for cleanup: Anthropic Claude, OpenAI GPT, OpenAI-compatible (Ollama, LM Studio), Google Gemini, Mistral.
- Desktop and Mobile profiles, auto-selected by environment with a manual override.
- 5 starter templates (General cleanup, Todo list, Daily note, Meeting notes, Idea capture); fully editable and reorderable.
@ -223,7 +223,6 @@ secrets.json.nosync
Obsidian on iOS and Android runs in a constrained WebView. A few things behave differently from desktop:
- **Web Speech (default mobile transcription provider)** is unavailable on iOS Obsidian (WKWebView does not implement `SpeechRecognition`). On Android, support is patchy. If Web Speech is unavailable, the modal surfaces a notice and you can switch to the Paste tab or pick a different transcription provider on your Mobile profile.
- **iOS screen-off**: `MediaRecorder` silently stops capturing audio when the screen turns off on iOS. The plugin cannot prevent this; keep the screen on while recording, or use the Paste tab with an OS-level dictation keyboard.
- **API keys are stored in plaintext on mobile** because Electron's `safeStorage` is not available. The `secrets.json.nosync` file still uses the `.nosync` filename so iCloud Drive will skip it, but for other sync tools you must apply the exclusion rules above.
- **Recording size limit**: clips over 25 MB are rejected. This is a transcription-API limit, not an Obsidian one, and is most likely to bite on long mobile recordings.

View file

@ -12,19 +12,14 @@ Phase A shipped (see Done). Remaining work:
- "Stop when idle for N minutes" toggle (default off; useful for the large-v3 user who doesn't want 1.5 GB resident all day).
- Process supervision hardening based on real-world Phase A usage (zombies, orphans, signal handling differences across platforms).
### 2. Mobile API management UX
### 2. Mobile API management UX — remaining sub-issues
Editing keys, base URLs, and model strings on mobile is rough. Two concrete issues plus one visual ask:
The visual differentiator (border + badge + collapsed inactive profile) shipped in Done. Two adjacent items still open:
- The two profile sections in [src/settings/tab.ts](../src/settings/tab.ts) render identically, so it's easy to edit the desktop profile while sitting on the phone. Add a visual differentiator (color accent, badge, or border on the active-on-this-device profile) so the mobile vs desktop section is unmistakable.
- The settings tab re-renders the entire container on dropdown changes (provider, insertMode, activeProfileOverride per [CLAUDE.md](../CLAUDE.md)'s Gotchas), which on mobile causes a visible scroll jump after committing a text field that sits next to a dropdown. Text fields already skip the re-render; audit whether any newer fields slipped into the redraw path and consider scrolling the focused row back into view after redraw.
- On mobile the on-screen keyboard frequently covers the input that just received focus. Likely needs a `scrollIntoView({ block: 'center' })` on the active control's focus/blur events, or a `keyboardWillShow`-equivalent hook through Obsidian's API.
- On mobile the on-screen keyboard frequently covers the input that just received focus. Likely needs a `scrollIntoView({ block: 'center' })` on the active control's focus/blur events, or a `keyboardWillShow`-equivalent hook through Obsidian's API. Needs device testing to characterize which fields, when on focus vs blur vs commit.
### 3. (Bug) Voxtral transcription model dropdown is empty
[src/transcription/mistral-voxtral.ts](../src/transcription/mistral-voxtral.ts) does not implement `listModels`, so the Refresh button on the model field is a no-op for the Voxtral transcription provider. Mistral's `/v1/models` returns the full catalog including chat models; filter to audio/Voxtral capability (probably `id.includes('voxtral')` for a v1) when surfacing transcription model IDs. Cross-check: the LLM-side Mistral provider already uses the shared OpenAI adapter's listing, so this gap is transcription-only.
### 4. Real-time transcription mode (live STT, no cleanup)
### 3. Real-time transcription mode (live STT, no cleanup)
Voxtral exposes a real-time STT model that doesn't accept whole-file uploads, and a few other providers have equivalents (Deepgram streaming, AssemblyAI realtime). Investigate adding an opt-in "Real-time" mode:
@ -33,11 +28,7 @@ Voxtral exposes a real-time STT model that doesn't accept whole-file uploads, an
- Provider-side gating: only enable for transcription providers that expose a realtime endpoint; document which.
- Honest assessment: this is lower-value than the rest of the plugin (transcript with no cleanup is what Obsidian's existing voice plugins already do). Ship only if users ask.
### 5. (Bug) Web Speech repeats short phrases indefinitely
The Web Speech adapter in [src/webspeech.ts](../src/webspeech.ts) currently loops short utterances: the same phrase is repeated over and over instead of producing one transcript line. Likely cause is the interim-result accumulator concatenating without deduping against final results, or the restart-on-end logic re-emitting buffered interim text. Needs investigation. Until fixed, document Web Speech as a "do not use" option in the README (or remove it from the provider dropdown).
### 6. Daily-note template: how far can the prompt drive a structured fill?
### 4. Daily-note template: how far can the prompt drive a structured fill?
The current daily-note default template is a freeform cleanup. Investigate whether the system prompt can reliably lay the transcript into a real structured daily-note template with named sections (Mood / Highlights / Tasks / Tomorrow / etc.) rather than emitting prose. References:
@ -50,6 +41,18 @@ Open questions: do we ship this as an updated default template (prompt-only), or
## Done
### Visual differentiation between desktop and mobile profile sections
[src/settings/tab.ts](../src/settings/tab.ts) `renderProfile()` now wraps each profile's settings in a `.rewrite-profile-section` div. The active-on-this-device profile (resolved via `detectActiveProfileKind` from [src/platform.ts](../src/platform.ts)) gets an `is-active-profile` modifier that adds an accent-colored left border, plus a `.rewrite-profile-active-badge` span inserted into the heading's `nameEl` reading "Active on this device". The inactive profile's body (everything below the heading) is rendered inside a `<details class="rewrite-profile-collapsed">` collapsed by default, with a "Show settings" summary. Expanded state lives on `ReWriteSettingTab.inactiveProfileExpanded` so it survives the full-container redraws triggered by provider/insertMode/activeProfileOverride dropdowns. New CSS classes in [styles.css](../styles.css): `.rewrite-profile-section`, `.rewrite-profile-section.is-active-profile`, `.rewrite-profile-active-badge`, `.rewrite-profile-collapsed`. The mobile keyboard-cover and scroll-jump sub-items from the original issue are deferred (see Open) pending device testing.
### Voxtral transcription model dropdown populates
[src/transcription/mistral-voxtral.ts](../src/transcription/mistral-voxtral.ts) gained a `listModels` method that fetches `https://api.mistral.ai/v1/models` and filters to IDs containing `voxtral` (case-insensitive), sorted. The shape mirrors the listModels in [src/transcription/openai.ts](../src/transcription/openai.ts), but stays a local filter rather than extending `filterAudioTranscriptionModels` (which is hardcoded to whisper/transcribe and owned by the OpenAI/Groq path). The settings tab's existing model-field dropdown + Refresh button pick up the new method automatically through the `typeof provider.listModels === 'function'` check.
### Removed the Web Speech transcription provider
The browser-native Web Speech API option (`'webspeech'`) is gone. An earlier idempotency fix to `src/webspeech.ts` stopped the "short phrase repeats indefinitely" symptom, but field testing showed the underlying provider still produced garbled output and froze after a few seconds, with no path to a reliable experience. With six remote providers plus local whisper.cpp covering both platforms, Web Speech wasn't worth keeping as a viable option. Removed: `src/webspeech.ts`, `src/transcription/webspeech.ts`, the `'webspeech'` variant of `TranscriptionProviderID`, the `'webspeech'` variant of `PipelineSource`, `isWebSpeechAvailable()` in [src/platform.ts](../src/platform.ts), the dropdown entry in [src/settings/tab.ts](../src/settings/tab.ts) and [src/ui/setup-card.ts](../src/ui/setup-card.ts), all `transcriptionProvider !== 'webspeech'` conditionals in those files, the Web Speech branches in [src/ui/modal.ts](../src/ui/modal.ts) and [src/ui/quick-record.ts](../src/ui/quick-record.ts), the `isWebSpeech` constructor param on `QuickRecordController`, the `webspeech` case in [src/transcription/limits.ts](../src/transcription/limits.ts), and all CLAUDE.md / README / eslint-config references. The mobile default `transcriptionProvider` changed from `'webspeech'` to `'openai'`. Pre-release rule: no migration code; dev installs with old data should delete `data.json`.
### Mode selector on the Quick Record floater
The Quick Record floating mini-UI ([src/ui/quick-record.ts](../src/ui/quick-record.ts)) gained a third button between the timer and Stop that opens a popover-style list of `plugin.templates`. Selecting an entry updates `QuickRecordController.template` in place and the button label so the user can see which template will run. Switching does NOT update `lastUsedTemplateId`; that still only happens after a successful completion. The popover is a child of the floater div, dismisses on selection / Escape / outside-click (capture-phase listeners on `document`, registered and torn down per open), and is force-closed when `setBusy` runs so the user can't switch templates after Stop is pressed. CSS additions in [styles.css](../styles.css): `.rewrite-quick-template` (the button), `.rewrite-quick-popover` (the floating list), `.rewrite-quick-popover-item` (rows, with an `is-active` modifier for the current selection). No destination-override UI on the floater per the v1 scope decision.

244
docs/MOBILE_KEYBOARD_BUG.md Normal file
View file

@ -0,0 +1,244 @@
# Mobile keyboard covers plugin text boxes (Android) — investigation log
Status: **UNRESOLVED.** Three substantively different code approaches have produced no
observable change on the user's Android device. This document records everything tried so
the problem can be tackled methodically (instrument first, then fix) rather than by more
blind guessing.
## Problem statement
On Android, when a text field managed by the plugin receives focus, the on-screen software
keyboard slides up and covers the text field. The field does not move out of the way, so the
user cannot see what they are typing. The expected behavior (seen in Obsidian core UIs and in
other plugins) is that the focused field scrolls/repositions above the keyboard.
Reporter platform: Android, Obsidian mobile. Desktop and iOS are not affected (the helper
early-returns on `!Platform.isMobile`, and iOS WebKit resizes the layout viewport when the
keyboard opens, which makes naive `scrollIntoView` work there).
## Test matrix
The reporter exercises these flows, positioning each field low enough that the keyboard would
overlap it:
| # | Flow | Surface | Container | Last reported result |
|---|------|---------|-----------|----------------------|
| 1 | Quick controls (bottom-right hamburger) → ReWrite → tap **Unlock** | Passphrase unlock prompt | `PassphraseModal` (popup) | FAIL |
| 2 | Quick controls → ReWrite → **Paste** → tap textarea | Paste tab textarea | `ReWriteModal` (popup) | FAIL |
| 3 | Sidebar → Settings gear → ReWrite (Voice Notes) → scroll to **Change passphrase** → tap both fields | Change-passphrase prompt | `PassphraseModal` (popup, `requireConfirm`) | FAIL |
| 4 | Sidebar → Settings gear → ReWrite (Voice Notes) → tap a field (transcription API key, assistant name, attachments folder, etc.) | Settings tab field | `ReWriteSettingTab` (settings panel) | PASS (confirmed still passing) |
**Disambiguation (confirmed by reporter):** test 4 still succeeds; tests 1-3 still fail. So the
split is stable: scrollable settings surface works, fixed-position popups do not.
**Critical caveat — this does NOT prove our code runs.** Test 4 passing is most likely *native
browser / Obsidian-core* behavior, not our helper. Chrome on Android natively scrolls a focused
input into view within its nearest scrollable ancestor, and is keyboard-aware when doing so. The
settings panel is exactly that: a tall scrollable surface. A fixed-position centered popup is not,
so native focus-scroll cannot move it and the field stays covered. In other words, the test 4 /
tests 1-3 split is fully explained by *the browser*, with our helper contributing nothing in any of
the four cases. **Hypothesis A (our code is a silent no-op because `visualViewport` never changes)
remains fully alive.** We still have zero positive evidence that `installMobileKeyboardScrollFix`
mutates anything on the device.
The reporter confirmed the build was deployed cleanly: they deleted the plugin's `main.js`,
`manifest.json`, and `styles.css` and recreated them (keeping `data.json` and
`secrets.json.nosync`), so stale-artifact caching is ruled out.
## The code under test
All attempts live in one helper: `installMobileKeyboardScrollFix(root)` in
[src/platform.ts](../src/platform.ts). It is attached (via `focusin`, which bubbles) to:
- [src/ui/modal.ts](../src/ui/modal.ts) line 31 — main modal `contentEl`
- [src/settings/tab.ts](../src/settings/tab.ts) line 69 — settings `containerEl`
- [src/ui/passphrase-modal.ts](../src/ui/passphrase-modal.ts) line 29 — passphrase modal `contentEl` (added this session)
- [src/insert.ts](../src/insert.ts) line 121 — `RenamePromptModal` `contentEl` (added this session)
The helper early-returns unless `Platform.isMobile`.
## Chronology of attempts
### Attempt 0 — pre-existing baseline (before this investigation)
- **Mechanism:** `focusin``setTimeout(300ms)``target.scrollIntoView({ block: 'center', behavior: 'smooth' })`.
- **Wired into:** main modal + settings tab only.
- **Theory:** scroll the focused input to the center of its scroll container after the keyboard
animation starts.
- **Why it can't work on Android:** `scrollIntoView({ block: 'center' })` centers the element in
the *layout* viewport (the full screen). Android (unlike iOS) does not shrink the layout
viewport when the keyboard opens, so "center of the full screen" is still behind the keyboard's
top edge for low fields.
### Attempt 1 — visualViewport + scroll nearest scrollable ancestor
- **Change:** Rewrote the helper to read `window.visualViewport`. On `focusin`: if the visual
viewport is already shrunk act immediately, else wait for a one-shot `visualViewport` `resize`
(600 ms safety timeout). Then compute `visibleBottom = vv.offsetTop + vv.height`, and if the
input's `rect.bottom` is below `visibleBottom - 16`, scroll the nearest scrollable ancestor by
the delta (fallback `window.scrollBy`). Kept a `scrollIntoView` fallback for when
`visualViewport` is unavailable.
- **Also:** wired the helper into `PassphraseModal` and `RenamePromptModal`.
- **Theory:** `visualViewport` reflects the real post-keyboard visible region on Android even
though the layout viewport does not, so we can scroll precisely.
- **Result:** Reporter: test 4 (settings) works; tests 1-3 (popups) fail.
### Attempt 2 — translate the `.modal` box via CSS transform
- **Change:** Added a branch: when no scrollable ancestor is found, walk up to the `.modal`
element and apply a composed `transform: <computed> translateY(-delta)` to lift the modal box,
restoring on `blur`. Composition read the current computed transform so as not to wipe
Obsidian's centering transform.
- **Theory:** short popups have no scroll room, so the scroll path did nothing; lift the whole
fixed-position modal instead.
- **Result:** Reporter: still broken for tests 1-3.
### Attempt 3 — shrink `.modal-container` so flex re-centers the popup
- **Change (current code):** Two-step in `liftAboveKeyboard`: (1) scroll the nearest scrollable
ancestor and re-measure; (2) if the input is still below the visible region, walk up to
`.modal-container` and set inline `top = vv.offsetTop` and `height = vv.height`, shrinking the
fixed-position container to the visible region so Obsidian's flex centering re-positions
`.modal` above the keyboard. Restore on `blur`; re-fit on subsequent `visualViewport` `resize`;
fully restore when `vv.height` returns to near `window.innerHeight`.
- **Theory:** `.modal-content` actually has `overflow: auto`, so Attempt 2's scroll path was being
taken and the transform branch never ran. Scrolling inside `.modal-content` shifts the input
within the modal but cannot move the fixed, centered modal itself; shrinking the container makes
the existing flex centering do the work.
- **Result:** Reporter: "No change for any of the tests."
## Current code (verbatim summary)
`liftAboveKeyboard(target, vv)`:
1. `visibleBottom = vv.offsetTop + vv.height`. If `rect.bottom <= visibleBottom - 16`, **return
(do nothing)** — we believe the input is already visible.
2. Scroll nearest scrollable ancestor by delta; re-measure; return if now visible.
3. Else shrink `.modal-container` (`top`/`height`), restore on blur.
4. Else `window.scrollBy`.
The entry path waits for `visualViewport.resize` (or acts immediately if `vv.height <
window.innerHeight - 100`), with a 600 ms fallback timer.
## Unverified assumptions (the heart of the problem)
We have been writing code against a mental model that has **never been confirmed on the actual
device**. Every one of these is a guess:
1. **That the `focusin` listener fires at all.** Never confirmed. If `Platform.isMobile` is
somehow false, or the listener is attached to the wrong element, nothing runs.
2. **That `window.visualViewport` shrinks when the Android keyboard opens.** This is the load-bearing
assumption for all three attempts, and it is the most suspect (see hypothesis A).
3. **That `window.innerHeight` does / doesn't change** when the keyboard opens (drives the
"already up" check and the restore condition).
4. **The DOM structure and class names** of Obsidian mobile modals (`.modal-container` > `.modal` >
`.modal-content`), and that `.modal-content` has `overflow: auto`, and that `.modal-container`
centers via flex. Never inspected on device.
5. **That step 1's early-return condition is ever false on the device.** If `visualViewport` does
not shrink, `rect.bottom <= visibleBottom - 16` is always true and the function is a guaranteed
no-op — which would perfectly explain "no change for any approach."
6. **Whether test 4 "working" is our code or Obsidian's own behavior.** If Obsidian core scrolls
its own settings panel on focus, test 4 would "pass" regardless of our helper, and we have zero
evidence our code does anything anywhere.
## Leading hypotheses (ranked)
### A. `visualViewport` does not reflect the keyboard in Obsidian's WebView (most likely)
Obsidian mobile is a Capacitor app. The Capacitor **Keyboard** plugin has a `resize` mode
(`native` / `body` / `ionic` / `none`). If Obsidian uses `resize: 'none'` (keyboard overlays
content), then **neither `window.innerHeight` nor `window.visualViewport.height` changes** when the
keyboard opens — the keyboard simply floats on top. In that case:
- The entry "already up" check (`vv.height < innerHeight - 100`) is never true.
- The `visualViewport` `resize` event may never fire, so we fall to the 600 ms timer.
- When `liftAboveKeyboard` finally runs, `visibleBottom` equals the full-screen bottom, so
`rect.bottom <= visibleBottom - 16` is true and **we return immediately, doing nothing.**
This single hypothesis explains why three completely different mutation strategies all produced
no visible change: **none of them ever executed their mutation.** This is the first thing to
verify.
If true, the correct signal is the Capacitor keyboard event, not `visualViewport`. Capacitor
dispatches DOM events on `window`: `keyboardWillShow` / `keyboardDidShow` (with
`event.keyboardHeight`) and `keyboardWillHide` / `keyboardDidHide`. Obsidian is known to surface
these. The fix would key off `keyboardHeight` instead of `visualViewport`.
### B. The listener never fires / wrong element
Less likely given the helper is attached at four call sites, but unproven. Would also explain
"no change anywhere."
### C. `visualViewport` works, but our DOM model is wrong
If `visualViewport` does shrink (so step 1 does not early-return) but `.modal-container` is not the
real class, or it does not center via flex, or `top/height` are overridden by `!important` rules,
then step 3 silently fails. This is plausible only if hypothesis A is false.
## Recommended next step: instrument before fixing
Stop guessing. Add a temporary on-screen diagnostic (a `Notice`, or a fixed debug `<div>`) that
fires on `focusin` and again ~400 ms later, dumping the real numbers. Because remote DevTools on
Android Obsidian is fiddly, an on-screen readout is the lowest-friction way to get ground truth.
Capture, at focus time and after a delay:
- `Platform.isMobile`
- `window.innerHeight` before vs. after keyboard
- `window.visualViewport?.height`, `.offsetTop`, `.pageTop` before vs. after
- whether a `visualViewport` `resize` event fired (counter)
- whether any `keyboardDidShow` window event fired, and its `keyboardHeight` if present
- the focused element's `getBoundingClientRect().bottom`
- the chain of ancestor class names from the input up to `body` (to confirm `.modal-container` /
`.modal` / `.modal-content` and which one, if any, is scrollable)
The single most important data point: **does `visualViewport.height` (or `innerHeight`) actually
decrease when the keyboard appears?** If no, hypothesis A is confirmed and we pivot to Capacitor
keyboard events. If yes, hypothesis A is dead and we debug the DOM model (hypothesis C).
If feasible, also enable remote debugging: connect the Android device over USB, open
`chrome://inspect` in desktop Chrome, inspect the Obsidian WebView, and watch the DOM/visualViewport
live while focusing a field. This is the gold-standard confirmation.
## Alternative fix approaches not yet tried
Pursue these only after instrumentation tells us which signal is real.
1. **Capacitor keyboard events (most promising if hypothesis A holds).** Listen on `window` for
`keyboardWillShow` / `keyboardDidShow` and read `event.keyboardHeight`. Apply a bottom inset
(e.g. `padding-bottom` on the scrollable region, or shrink `.modal-container` by
`keyboardHeight`) and reverse it on `keyboardWillHide` / `keyboardDidHide`. This does not depend
on `visualViewport` updating.
2. **CSS environment inset.** Newer WebViews expose `env(keyboard-inset-height)` /
`env(keyboard-inset-bottom)` when the page opts in via the `interactive-widget` viewport meta —
but a plugin cannot set the viewport meta, so this likely requires Obsidian-level support.
Worth testing whether the env vars are non-zero on the device anyway.
3. **Pure CSS positioning of popups.** Anchor `.rewrite-modal` / `.rewrite-passphrase-modal` to the
top of the screen on mobile (e.g. `.is-mobile .modal { top: 8px; transform: none; }`) so even a
non-moving modal sits above where the keyboard appears. Crude, but independent of any JS signal,
and a useful diagnostic: if a top-anchored modal is still covered, the keyboard is taller than
assumed or our CSS is being overridden.
3b. **Make popups behave like the settings surface (highest-leverage, given the confirmed split).**
The one thing that demonstrably works is a tall scrollable container (settings tab) where native
keyboard-aware focus-scroll does the job. Mimic that for popups: on mobile, top-anchor the popup
and give its content a scrollable region that can extend below the fold
(`.is-mobile .rewrite-modal { top: 0; transform: none; max-height: 100%; }` plus an
`overflow-y: auto` content area with enough height that the focused field can scroll up). If the
browser then scrolls popup fields into view the same way it does settings fields, the bug is
fixed without relying on `visualViewport` or Capacitor events at all. This is CSS-first and
signal-independent, and it directly transplants the working case onto the failing cases — try it
before the JS-signal approaches.
4. **Ask whether Obsidian already exposes a hook.** Check Obsidian's mobile behavior and any
documented API for keyboard insets before reinventing it; mirror whatever the core settings
panel does (since test 4 historically worked, the core mechanism is worth copying directly).
5. **Confirm the `webspeech` removal / unrelated churn isn't interfering.** Unlikely, but the
working tree has broad uncommitted changes; verify the helper is actually the code shipping in
`main.js` (search the built bundle for `visualViewport` / `modal-container`).
## Key references
- Helper: [src/platform.ts](../src/platform.ts) `installMobileKeyboardScrollFix` / `liftAboveKeyboard`
- Call sites: [src/ui/modal.ts](../src/ui/modal.ts):31, [src/settings/tab.ts](../src/settings/tab.ts):69, [src/ui/passphrase-modal.ts](../src/ui/passphrase-modal.ts):29, [src/insert.ts](../src/insert.ts):121
- Digital Garden plugin (referenced by the reporter as a working example): its Appearance modal is a
bare `new Modal(app)` with **no** keyboard-handling code — its good behavior comes from Obsidian
core, not plugin code. So "copy Digital Garden" reduces to "copy Obsidian core's modal behavior."
- Gotcha entry summarizing the helper: [CLAUDE.md](../CLAUDE.md) (search "Mobile keyboard scroll").

View file

@ -5,7 +5,7 @@ import { globalIgnores } from "eslint/config";
// Mirror the plugin's DEFAULT_BRANDS / DEFAULT_ACRONYMS (the rule replaces the
// defaults when options are passed, so we have to provide the full lists) plus
// the extras this project needs: provider names, "Web Speech", "ReWrite", "LLM".
// the extras this project needs: provider names, "ReWrite", "LLM".
const DEFAULT_BRANDS = [
"iOS", "iPadOS", "macOS", "Windows", "Android", "Linux",
"Obsidian", "Obsidian Sync", "Obsidian Publish",
@ -33,7 +33,6 @@ const DEFAULT_ACRONYMS = [
const REWRITE_BRANDS = [
"ReWrite",
"Web Speech",
"OpenAI", "Whisper",
"Anthropic", "Claude",
"Google Gemini", "Gemini",

View file

@ -1,4 +1,5 @@
import { App, MarkdownView, Modal, moment, normalizePath, Notice, Setting, TFile } from 'obsidian';
import { installMobileKeyboardScrollFix } from './platform';
import { NewFileCollisionMode, NoteTemplate } from './types';
export type InsertStage = 'cursor' | 'newFile' | 'append';
@ -117,6 +118,7 @@ class RenamePromptModal extends Modal {
onOpen(): void {
const { contentEl } = this;
this.modalEl.addClass('rewrite-rename-modal');
installMobileKeyboardScrollFix(contentEl);
contentEl.createEl('h2', { text: 'File already exists' });
contentEl.createEl('p', { text: `A file already exists at ${this.conflictPath}. Choose a new path.` });

View file

@ -16,6 +16,11 @@ export interface LLMProvider {
export function createLLMProvider(id: LLMProviderID): LLMProvider {
switch (id) {
case 'none':
return {
id: 'none',
complete: async (_systemPrompt, userMessage) => userMessage,
};
case 'anthropic':
return createAnthropicLLM();
case 'openai':

View file

@ -14,7 +14,6 @@ export type PipelineStage = 'persist-audio' | 'transcribe' | 'cleanup' | 'insert
export type PipelineSource =
| { kind: 'audio'; audio: Blob; sourcePath?: string; durationMs?: number }
| { kind: 'paste'; text: string }
| { kind: 'webspeech'; transcript: string }
| { kind: 'text'; text: string };
export interface PipelineParams {
@ -87,9 +86,10 @@ async function collectTranscript(params: PipelineParams): Promise<string> {
case 'paste':
case 'text':
return source.text;
case 'webspeech':
return source.transcript;
case 'audio': {
if (params.profile.transcriptionProvider === 'none') {
throw new Error('Transcription is disabled (provider set to None). Use the Paste or From note tab instead.');
}
validateRecording(source.audio.size, source.durationMs, params.profile.transcriptionProvider);
params.onStage?.('transcribe');
const provider = createTranscriptionProvider(params.profile.transcriptionProvider);
@ -99,6 +99,12 @@ async function collectTranscript(params: PipelineParams): Promise<string> {
}
async function cleanupTranscript(params: PipelineParams, transcript: string): Promise<string> {
// LLM=none: insert the transcript as-is. Skips wake-name extraction and
// known-nouns injection too, because both only matter when an LLM consumes
// the system prompt.
if (params.profile.llmProvider === 'none') {
return transcript;
}
let systemPrompt = params.template.prompt;
let workingTranscript = transcript;
if (params.settings.adHocInstructionsEnabled && params.settings.assistantName.trim().length > 0) {

View file

@ -26,10 +26,147 @@ export function isMediaRecorderAvailable(): boolean {
return typeof MediaRecorder !== 'undefined' && typeof navigator !== 'undefined' && !!navigator.mediaDevices;
}
export function isWebSpeechAvailable(): boolean {
const w = window as unknown as {
SpeechRecognition?: unknown;
webkitSpeechRecognition?: unknown;
};
return Boolean(w.SpeechRecognition || w.webkitSpeechRecognition);
// Mobile soft-keyboards routinely cover whichever input just received focus.
// This helper attaches a `focusin` listener to `root` and, when on mobile,
// brings the focused input/textarea into the visible region above the
// keyboard. Android WebViews do NOT resize the layout viewport when the
// keyboard opens (iOS does), so a bare `scrollIntoView({ block: 'center' })`
// centers the input on the full screen, which is then covered by the
// keyboard. We use `window.visualViewport` to read the actual post-keyboard
// visible region, then:
// 1. Scroll the nearest scrollable ancestor by the delta needed to lift
// the input. On the settings tab this fully resolves the case.
// 2. If the input is still below the visible region (typical for short
// Obsidian popups whose `.modal-content` has no scroll room), shrink
// the enclosing `.modal-container` to the visible region via inline
// `top`/`height`. Obsidian centers `.modal` inside `.modal-container`
// via flex, so the popup re-positions above the keyboard. Restore on
// blur. Scrolling cannot move a `position: fixed` modal up by itself.
// Falls back to `scrollIntoView` when `visualViewport` is unavailable. Safe
// to call on desktop; it returns immediately. The caller's element is
// expected to outlive the listener (Obsidian re-empties containers across
// renders rather than detaching them), so no explicit teardown is provided.
export function installMobileKeyboardScrollFix(root: HTMLElement): void {
if (!Platform.isMobile) return;
root.addEventListener('focusin', (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement)) return;
const vv = window.visualViewport;
if (!vv) {
window.setTimeout(() => {
try {
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
} catch {
target.scrollIntoView();
}
}, 300);
return;
}
const act = () => liftAboveKeyboard(target, vv);
// If the keyboard is already up (visual viewport already shrunk), act now.
if (vv.height < window.innerHeight - 100) {
window.setTimeout(act, 50);
return;
}
// Otherwise wait for the visual viewport to shrink (keyboard opens),
// with a safety timeout so we still scroll if no resize fires.
let done = false;
const onResize = () => {
if (done) return;
done = true;
vv.removeEventListener('resize', onResize);
window.clearTimeout(timer);
act();
};
const timer = window.setTimeout(() => {
if (done) return;
done = true;
vv.removeEventListener('resize', onResize);
act();
}, 600);
vv.addEventListener('resize', onResize);
});
}
function liftAboveKeyboard(target: HTMLElement, vv: VisualViewport): void {
const margin = 16;
const visibleBottom = vv.offsetTop + vv.height;
let rect = target.getBoundingClientRect();
if (rect.bottom <= visibleBottom - margin) return;
let delta = rect.bottom - (visibleBottom - margin);
// First try scrolling the nearest scrollable ancestor. This is what works
// on the settings page (the inner tab-content scrolls and the input rises
// into the visible region).
const scrollable = findScrollableAncestor(target);
if (scrollable) {
scrollable.scrollTop += delta;
rect = target.getBoundingClientRect();
if (rect.bottom <= visibleBottom - margin) return;
delta = rect.bottom - (visibleBottom - margin);
}
// Still hidden. The input lives inside a `position: fixed` popup whose
// `.modal-content` doesn't have enough scroll room (passphrase modal,
// rename prompt, the Paste tab modal, etc.). Shrink the enclosing
// `.modal-container` to the visible region; Obsidian centers `.modal`
// inside it via flex, so the popup re-positions above the keyboard.
const container = findModalContainer(target);
if (container) {
applyModalContainerLift(container, target, vv);
return;
}
window.scrollBy(0, delta);
}
function findScrollableAncestor(el: HTMLElement): HTMLElement | null {
let node: HTMLElement | null = el.parentElement;
while (node && node !== document.body) {
const style = window.getComputedStyle(node);
const overflowY = style.overflowY;
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
return node;
}
node = node.parentElement;
}
return null;
}
function findModalContainer(el: HTMLElement): HTMLElement | null {
let node: HTMLElement | null = el.parentElement;
while (node && node !== document.body) {
if (node.classList.contains('modal-container')) return node;
node = node.parentElement;
}
return null;
}
function applyModalContainerLift(container: HTMLElement, target: HTMLElement, vv: VisualViewport): void {
const prevTop = container.style.top;
const prevHeight = container.style.height;
container.style.top = `${vv.offsetTop}px`;
container.style.height = `${vv.height}px`;
let restored = false;
const restore = () => {
if (restored) return;
restored = true;
target.removeEventListener('blur', onBlur);
vv.removeEventListener('resize', onResize);
container.style.top = prevTop;
container.style.height = prevHeight;
};
const onBlur = () => restore();
const onResize = () => {
// Keyboard closed: visual viewport returns to roughly full window height.
if (vv.height >= window.innerHeight - 50) restore();
else {
// Keyboard height may have changed (e.g., emoji panel toggled);
// re-fit the container.
container.style.top = `${vv.offsetTop}px`;
container.style.height = `${vv.height}px`;
}
};
target.addEventListener('blur', onBlur);
vv.addEventListener('resize', onResize);
}

View file

@ -33,7 +33,7 @@ const DESKTOP_DEFAULT_PROFILE: EnvironmentProfile = {
const MOBILE_DEFAULT_PROFILE: EnvironmentProfile = {
name: 'Mobile',
transcriptionProvider: 'webspeech',
transcriptionProvider: 'openai',
transcriptionConfig: { ...EMPTY_TRANSCRIPTION_CONFIG },
llmProvider: 'anthropic',
llmConfig: { ...EMPTY_LLM_CONFIG },

View file

@ -11,7 +11,7 @@ import {
TranscriptionConfig,
TranscriptionProviderID,
} from '../types';
import { detectActiveProfileKind } from '../platform';
import { detectActiveProfileKind, installMobileKeyboardScrollFix } from '../platform';
import { createTranscriptionProvider } from '../transcription';
import { createLLMProvider } from '../llm';
import { formatWhisperStatus } from '../whisper-host';
@ -30,8 +30,8 @@ const TRANSCRIPTION_OPTIONS: Array<{ id: TranscriptionProviderID; label: string;
{ id: 'deepgram', label: 'Deepgram' },
{ id: 'revai', label: 'Rev.ai' },
{ id: 'mistral-voxtral', label: 'Mistral Voxtral' },
{ id: 'webspeech', label: 'Web Speech (browser)' },
{ id: 'whisper-local', label: 'Local whisper.cpp (desktop only)', desktopOnly: true },
{ id: 'none', label: 'None (text-only; recording disabled)' },
];
const LLM_OPTIONS: Array<{ id: LLMProviderID; label: string }> = [
@ -40,6 +40,7 @@ const LLM_OPTIONS: Array<{ id: LLMProviderID; label: string }> = [
{ id: 'openai-compatible', label: 'OpenAI-compatible (local server)' },
{ id: 'gemini', label: 'Google Gemini' },
{ id: 'mistral', label: 'Mistral' },
{ id: 'none', label: 'None (skip cleanup; insert raw text)' },
];
const RECORDING_FORMAT_OPTIONS: Array<{ id: RecordingFormatPreference; label: string }> = [
@ -48,6 +49,12 @@ const RECORDING_FORMAT_OPTIONS: Array<{ id: RecordingFormatPreference; label: st
];
export class ReWriteSettingTab extends PluginSettingTab {
// Tracks whether the inactive-on-this-device profile is expanded. Survives
// the full-container redraws that fire when dropdowns toggle conditional
// fields (provider, insertMode, activeProfileOverride).
private inactiveProfileExpanded = false;
private keyboardScrollFixInstalled = false;
constructor(app: App, private readonly plugin: ReWritePlugin) {
super(app, plugin);
}
@ -57,6 +64,12 @@ export class ReWriteSettingTab extends PluginSettingTab {
containerEl.empty();
containerEl.addClass('rewrite-settings');
// containerEl is reused across display() calls, so install once.
if (!this.keyboardScrollFixInstalled) {
installMobileKeyboardScrollFix(containerEl);
this.keyboardScrollFixInstalled = true;
}
this.renderEncryption(containerEl);
this.renderActiveProfile(containerEl);
this.renderProfile(containerEl, 'desktop');
@ -245,9 +258,33 @@ export class ReWriteSettingTab extends PluginSettingTab {
? this.plugin.settings.desktopProfile
: this.plugin.settings.mobileProfile;
const title = kind === 'desktop' ? 'Desktop profile' : 'Mobile profile';
new Setting(parent).setName(title).setHeading();
const isActive = detectActiveProfileKind(this.plugin.settings) === kind;
new Setting(parent)
const section = parent.createDiv({ cls: 'rewrite-profile-section' });
if (isActive) section.addClass('is-active-profile');
const heading = new Setting(section).setName(title).setHeading();
if (isActive) {
heading.nameEl.createSpan({
cls: 'rewrite-profile-active-badge',
text: 'Active on this device',
});
}
let body: HTMLElement;
if (isActive) {
body = section;
} else {
const details = section.createEl('details', { cls: 'rewrite-profile-collapsed' });
details.open = this.inactiveProfileExpanded;
details.addEventListener('toggle', () => {
this.inactiveProfileExpanded = details.open;
});
details.createEl('summary', { text: 'Show settings' });
body = details;
}
new Setting(body)
.setName('Profile label')
.setDesc('Display name for this profile.')
.addText((t) => {
@ -258,7 +295,7 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
});
new Setting(parent)
new Setting(body)
.setName('Transcription provider')
.addDropdown((dd) => {
for (const opt of TRANSCRIPTION_OPTIONS) {
@ -273,11 +310,11 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
});
if (profile.transcriptionProvider !== 'webspeech') {
this.renderTranscriptionModelField(parent, profile);
if (profile.transcriptionProvider !== 'none') {
this.renderTranscriptionModelField(body, profile);
if (profile.transcriptionProvider === 'openai-compatible') {
new Setting(parent)
new Setting(body)
.setName('Transcription base URL')
.setDesc('e.g. http://localhost:8080 (whisper.cpp, faster-whisper-server)')
.addText((t) => {
@ -290,7 +327,7 @@ export class ReWriteSettingTab extends PluginSettingTab {
}
if (profile.transcriptionProvider !== 'whisper-local') {
new Setting(parent)
new Setting(body)
.setName('Transcription API key')
.addText((t) => {
t.inputEl.type = 'password';
@ -306,7 +343,7 @@ export class ReWriteSettingTab extends PluginSettingTab {
}
}
new Setting(parent)
new Setting(body)
.setName('LLM provider')
.addDropdown((dd) => {
for (const opt of LLM_OPTIONS) dd.addOption(opt.id, opt.label);
@ -318,43 +355,47 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
});
this.renderLLMModelField(parent, profile);
if (profile.llmProvider !== 'none') {
this.renderLLMModelField(body, profile);
if (profile.llmProvider === 'openai-compatible') {
new Setting(parent)
.setName('LLM base URL')
.setDesc('e.g. http://localhost:11434/v1 (Ollama) or http://localhost:1234/v1 (LM Studio)')
if (profile.llmProvider === 'openai-compatible') {
new Setting(body)
.setName('LLM base URL')
.setDesc('e.g. http://localhost:11434/v1 (Ollama) or http://localhost:1234/v1 (LM Studio)')
.addText((t) => {
t.setValue(profile.llmConfig.baseUrl);
t.onChange(async (v) => {
profile.llmConfig.baseUrl = v;
await this.commit();
});
});
}
new Setting(body)
.setName('LLM API key')
.addText((t) => {
t.setValue(profile.llmConfig.baseUrl);
t.inputEl.type = 'password';
this.applyApiKeyFieldState(t.inputEl);
t.setPlaceholder(this.apiKeyPlaceholder());
t.setValue(profile.llmConfig.apiKey);
t.onChange(async (v) => {
profile.llmConfig.baseUrl = v;
if (this.plugin.encryptionStatus.locked) return;
profile.llmConfig.apiKey = v;
await this.commit();
});
});
}
new Setting(parent)
.setName('LLM API key')
.addText((t) => {
t.inputEl.type = 'password';
this.applyApiKeyFieldState(t.inputEl);
t.setPlaceholder(this.apiKeyPlaceholder());
t.setValue(profile.llmConfig.apiKey);
t.onChange(async (v) => {
if (this.plugin.encryptionStatus.locked) return;
profile.llmConfig.apiKey = v;
await this.commit();
});
});
this.renderProfileAdvanced(parent, profile);
this.renderProfileAdvanced(body, profile);
}
private renderProfileAdvanced(parent: HTMLElement, profile: EnvironmentProfile): void {
if (profile.transcriptionProvider === 'none' && profile.llmProvider === 'none') return;
const details = parent.createEl('details', { cls: 'rewrite-advanced' });
details.createEl('summary', { text: 'Advanced' });
if (profile.transcriptionProvider !== 'webspeech') {
if (profile.transcriptionProvider !== 'none') {
new Setting(details)
.setName('Transcription language')
.setDesc('Optional language hint. Leave blank to auto-detect.')
@ -367,18 +408,20 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
}
new Setting(details)
.setName('LLM max tokens')
.setDesc('Maximum tokens for the cleanup response. Default 2048.')
.addText((t) => {
t.inputEl.type = 'number';
t.setValue(String(profile.llmConfig.maxTokens));
t.onChange(async (v) => {
const n = Number.parseInt(v, 10);
profile.llmConfig.maxTokens = Number.isFinite(n) && n > 0 ? n : 2048;
await this.commit();
if (profile.llmProvider !== 'none') {
new Setting(details)
.setName('LLM max tokens')
.setDesc('Maximum tokens for the cleanup response. Default 2048.')
.addText((t) => {
t.inputEl.type = 'number';
t.setValue(String(profile.llmConfig.maxTokens));
t.onChange(async (v) => {
const n = Number.parseInt(v, 10);
profile.llmConfig.maxTokens = Number.isFinite(n) && n > 0 ? n : 2048;
await this.commit();
});
});
});
}
}
private renderTranscriptionModelField(parent: HTMLElement, profile: EnvironmentProfile): void {
@ -690,7 +733,7 @@ export class ReWriteSettingTab extends PluginSettingTab {
new Setting(parent).setName('Recording').setHeading();
new Setting(parent)
.setName('Audio format preference')
.setDesc('Web Speech ignores this. Use mp4 on iOS, otherwise webm.')
.setDesc('Use mp4 on iOS, otherwise webm.')
.addDropdown((dd) => {
for (const opt of RECORDING_FORMAT_OPTIONS) dd.addOption(opt.id, opt.label);
dd.setValue(this.plugin.settings.recordingFormat);
@ -862,6 +905,8 @@ function modelFieldDesc(hint: string, supportsList: boolean, cachedCount: number
function transcriptionModelHint(id: TranscriptionProviderID): string {
switch (id) {
case 'none':
return '';
case 'openai':
return 'e.g. whisper-1';
case 'groq':
@ -878,13 +923,13 @@ function transcriptionModelHint(id: TranscriptionProviderID): string {
return 'Whichever model your local server exposes';
case 'whisper-local':
return 'Any value works; the loaded model is set at server start.';
case 'webspeech':
return '';
}
}
function llmModelHint(id: LLMProviderID): string {
switch (id) {
case 'none':
return '';
case 'anthropic':
return 'e.g. claude-sonnet-4-5 or claude-haiku-4-5-20251001';
case 'openai':

View file

@ -4,7 +4,6 @@ import { createAssemblyAITranscription } from './assemblyai';
import { createDeepgramTranscription } from './deepgram';
import { createRevAITranscription } from './revai';
import { createMistralVoxtralTranscription } from './mistral-voxtral';
import { createWebSpeechTranscription } from './webspeech';
import { createWhisperLocalTranscription } from './whisper-local';
export interface TranscriptionProvider {
@ -22,6 +21,14 @@ export function createTranscriptionProvider(
id: TranscriptionProviderID,
): TranscriptionProvider {
switch (id) {
case 'none':
return {
id: 'none',
requiresAudio: false,
transcribe: async () => {
throw new Error('Transcription is disabled (provider set to None). Use the Paste or From note tab instead.');
},
};
case 'openai':
case 'openai-compatible':
case 'groq':
@ -34,8 +41,6 @@ export function createTranscriptionProvider(
return createRevAITranscription();
case 'mistral-voxtral':
return createMistralVoxtralTranscription();
case 'webspeech':
return createWebSpeechTranscription();
case 'whisper-local':
return createWhisperLocalTranscription();
}

View file

@ -7,7 +7,7 @@ import { TranscriptionProviderID } from '../types';
// - Deepgram: 2 GB sync (developers.deepgram.com)
// - Rev.ai: 2 GB multipart / 17 h (docs.rev.ai/api/asynchronous)
// - Mistral Voxtral: 1 GB / 30 min (docs.mistral.ai/api/endpoint/audio/transcriptions)
// - openai-compatible / whisper-local / webspeech: no client-side cap
// - openai-compatible / whisper-local: no client-side cap
export interface TranscriptionLimits {
readonly maxBytes?: number;
readonly maxDurationMs?: number;
@ -20,6 +20,8 @@ const HOUR = 60 * MIN;
export function getTranscriptionLimits(id: TranscriptionProviderID): TranscriptionLimits {
switch (id) {
case 'none':
return {};
case 'openai':
return { maxBytes: 25 * MB };
case 'groq':
@ -34,13 +36,13 @@ export function getTranscriptionLimits(id: TranscriptionProviderID): Transcripti
return { maxBytes: 1 * GB, maxDurationMs: 30 * MIN };
case 'openai-compatible':
case 'whisper-local':
case 'webspeech':
return {};
}
}
export function transcriptionProviderLabel(id: TranscriptionProviderID): string {
switch (id) {
case 'none': return 'None';
case 'openai': return 'OpenAI Whisper';
case 'groq': return 'Groq';
case 'assemblyai': return 'AssemblyAI';
@ -49,7 +51,6 @@ export function transcriptionProviderLabel(id: TranscriptionProviderID): string
case 'mistral-voxtral': return 'Mistral Voxtral';
case 'openai-compatible': return 'OpenAI-compatible';
case 'whisper-local': return 'Local whisper.cpp';
case 'webspeech': return 'Web Speech';
}
}

View file

@ -1,23 +1,28 @@
import { TranscriptionConfig } from '../types';
import { MultipartPart, multipartPost, ProviderError } from '../http';
import { jsonGet, MultipartPart, multipartPost, ProviderError } from '../http';
import { transcodeToWavPcm } from '../audio-transcode';
import { TranscriptionProvider } from './index';
// Mistral Voxtral diverges from the OpenAI Whisper shape on three points, so it
// Mistral Voxtral diverges from the OpenAI Whisper shape on two points, so it
// gets its own adapter rather than dispatching through openai.ts:
// 1. Response is JSON only ({ text, segments, ... }); no response_format=text.
// 2. WebM/Opus is not an accepted input format, so the recorded blob is always
// transcoded to 16 kHz mono WAV before upload (same path as whisper-local).
// 30 min of 16 kHz mono 16-bit PCM is ~57 MB, well under the 1 GB cap.
// 3. /v1/models does not document audio-model surfacing, so listModels is omitted.
// listModels fetches the Mistral catalog and filters by ID substring `voxtral`.
const VOXTRAL_ENDPOINT = 'https://api.mistral.ai/v1/audio/transcriptions';
const MISTRAL_MODELS_ENDPOINT = 'https://api.mistral.ai/v1/models';
interface VoxtralResponse {
text?: unknown;
}
interface MistralModelsResponse {
data?: Array<{ id?: unknown }>;
}
export function createMistralVoxtralTranscription(): TranscriptionProvider {
return {
const provider: TranscriptionProvider = {
id: 'mistral-voxtral',
requiresAudio: true,
async transcribe(
@ -62,4 +67,38 @@ export function createMistralVoxtralTranscription(): TranscriptionProvider {
return text.trim();
},
};
provider.listModels = async (config, signal) => {
if (!config.apiKey) throw new Error('mistral-voxtral: API key is not configured');
const response = await jsonGet<MistralModelsResponse>(
'mistral-voxtral',
MISTRAL_MODELS_ENDPOINT,
{ Authorization: `Bearer ${config.apiKey}` },
signal,
);
return filterVoxtralModels(response.data ?? []);
};
return provider;
}
function filterVoxtralModels(rows: Array<{ id?: unknown }>): string[] {
// Match Voxtral by name first, but also fall back to capability hints so we
// pick up audio-capable models if Mistral ever renames the family or adds
// new variants. Exclude obvious non-audio (chat/embed/moderation/etc).
const out: string[] = [];
for (const row of rows) {
const id = typeof row.id === 'string' ? row.id : '';
if (!id) continue;
const lower = id.toLowerCase();
if (
lower.includes('voxtral')
|| lower.includes('audio')
|| lower.includes('transcribe')
) {
out.push(id);
}
}
out.sort();
return out;
}

View file

@ -1,18 +0,0 @@
import { TranscriptionConfig } from '../types';
import { TranscriptionProvider } from './index';
// Web Speech does not produce an audio blob. The recorder (src/webspeech.ts) drives a
// SpeechRecognition session and emits a finalized transcript directly. The pipeline must
// short-circuit when source === 'webspeech' so this transcribe() is never called.
// It exists only so the factory has a complete switch case.
export function createWebSpeechTranscription(): TranscriptionProvider {
return {
id: 'webspeech',
requiresAudio: false,
async transcribe(_audio: Blob, _config: TranscriptionConfig): Promise<string> {
throw new Error(
'webspeech: transcribe() should not be called; the pipeline must use the live SpeechRecognition transcript',
);
},
};
}

View file

@ -1,4 +1,5 @@
export type TranscriptionProviderID =
| 'none'
| 'openai'
| 'openai-compatible'
| 'groq'
@ -6,10 +7,10 @@ export type TranscriptionProviderID =
| 'deepgram'
| 'revai'
| 'mistral-voxtral'
| 'webspeech'
| 'whisper-local';
export type LLMProviderID =
| 'none'
| 'anthropic'
| 'openai'
| 'openai-compatible'

View file

@ -35,6 +35,10 @@ export async function runAudioFilePipeline(
plugin.promptUnlock();
return;
}
if (profile.transcriptionProvider === 'none') {
new Notice('ReWrite: transcription is disabled for this profile. Pick a transcription provider in settings to reprocess audio.');
return;
}
if (!isProfileConfigured(profile)) {
new Notice('ReWrite: configure a transcription and LLM provider before reprocessing audio.');
new ReWriteModal(plugin.app, plugin).open();

View file

@ -1,9 +1,8 @@
import { App, Modal, Notice } from 'obsidian';
import type ReWritePlugin from '../main';
import { runPipeline, PipelineSource, PipelineStage } from '../pipeline';
import { isMediaRecorderAvailable, isWebSpeechAvailable, resolveActiveProfile } from '../platform';
import { installMobileKeyboardScrollFix, isMediaRecorderAvailable, resolveActiveProfile } from '../platform';
import { Recorder } from '../recorder';
import { startWebSpeech, WebSpeechSession } from '../webspeech';
import { DestinationOverride, EnvironmentProfile, InsertMode, NoteTemplate } from '../types';
import { isProfileConfigured, isProfileConfiguredForText, renderSetupCard } from './setup-card';
import { resolveActiveTextSource } from './text-source';
@ -12,7 +11,6 @@ export class ReWriteModal extends Modal {
private templateId: string;
private activeTab: 'record' | 'paste' | 'fromNote' = 'record';
private recorder: Recorder | null = null;
private webSpeech: WebSpeechSession | null = null;
private timerHandle: number | null = null;
private running = false;
private currentSource: PipelineSource | null = null;
@ -30,6 +28,7 @@ export class ReWriteModal extends Modal {
onOpen(): void {
this.modalEl.addClass('rewrite-modal');
installMobileKeyboardScrollFix(this.contentEl);
this.render();
}
@ -95,7 +94,13 @@ export class ReWriteModal extends Modal {
}
if (this.activeTab === 'record') {
this.renderRecordTab(tabBody, profile.transcriptionProvider === 'webspeech', profile.transcriptionConfig.language);
if (profile.transcriptionProvider === 'none') {
tabBody.createEl('p', {
text: 'Recording is disabled because this profile has no transcription provider configured. Switch to one of the other tabs above, or pick a transcription provider in settings.',
});
return;
}
this.renderRecordTab(tabBody);
} else {
this.renderPasteTab(tabBody);
}
@ -271,14 +276,8 @@ export class ReWriteModal extends Modal {
});
}
private renderRecordTab(parent: HTMLElement, isWebSpeech: boolean, language: string): void {
if (isWebSpeech && !isWebSpeechAvailable()) {
parent.createEl('p', {
text: 'Web Speech is not available here. Use the paste tab or pick a different transcription provider in settings.',
});
return;
}
if (!isWebSpeech && !isMediaRecorderAvailable()) {
private renderRecordTab(parent: HTMLElement): void {
if (!isMediaRecorderAvailable()) {
parent.createEl('p', {
text: 'Audio recording is not supported in this environment. Use the paste tab instead.',
});
@ -293,16 +292,13 @@ export class ReWriteModal extends Modal {
const dot = indicator.createSpan({ cls: 'rewrite-pulse-dot' });
dot.hide();
const timer = indicator.createSpan({ cls: 'rewrite-timer', text: '0:00' });
const liveTranscript = parent.createDiv({ cls: 'rewrite-live-transcript' });
let isRecording = false;
const handleClick = async (): Promise<void> => {
if (this.running) return;
if (!isRecording) {
try {
await this.beginCapture(isWebSpeech, language, (text) => {
liveTranscript.setText(text);
});
await this.beginCapture();
} catch (e) {
new Notice(e instanceof Error ? e.message : String(e));
return;
@ -310,11 +306,11 @@ export class ReWriteModal extends Modal {
isRecording = true;
button.setText('Stop');
dot.show();
this.startTimerLoop(timer, isWebSpeech);
this.startTimerLoop(timer);
} else {
button.disabled = true;
try {
const source = await this.endCapture(isWebSpeech);
const source = await this.endCapture();
isRecording = false;
button.setText('Record');
dot.hide();
@ -379,38 +375,21 @@ export class ReWriteModal extends Modal {
});
}
private async beginCapture(
isWebSpeech: boolean,
language: string,
onLiveText: (text: string) => void,
): Promise<void> {
if (isWebSpeech) {
this.webSpeech = startWebSpeech({
language: language || undefined,
onUpdate: onLiveText,
});
} else {
this.recorder = new Recorder();
await this.recorder.start(this.plugin.settings.recordingFormat);
}
private async beginCapture(): Promise<void> {
this.recorder = new Recorder();
await this.recorder.start(this.plugin.settings.recordingFormat);
}
private async endCapture(isWebSpeech: boolean): Promise<PipelineSource> {
if (isWebSpeech) {
const transcript = (await this.webSpeech?.stop()) ?? '';
this.webSpeech = null;
return { kind: 'webspeech', transcript };
}
private async endCapture(): Promise<PipelineSource> {
if (!this.recorder) throw new Error('No active recording.');
const result = await this.recorder.stop();
this.recorder = null;
return { kind: 'audio', audio: result.blob, durationMs: result.durationMs };
}
private startTimerLoop(timerEl: HTMLElement, isWebSpeech: boolean): void {
const startedAt = Date.now();
private startTimerLoop(timerEl: HTMLElement): void {
this.timerHandle = window.setInterval(() => {
const ms = isWebSpeech ? Date.now() - startedAt : this.recorder?.getElapsedMs() ?? 0;
const ms = this.recorder?.getElapsedMs() ?? 0;
timerEl.setText(formatDuration(ms));
}, 250);
}
@ -426,8 +405,6 @@ export class ReWriteModal extends Modal {
this.stopTimerLoop();
this.recorder?.cancel();
this.recorder = null;
this.webSpeech?.cancel();
this.webSpeech = null;
}
private async execute(source: PipelineSource): Promise<void> {

View file

@ -1,4 +1,5 @@
import { App, Modal, Notice, Setting } from 'obsidian';
import { installMobileKeyboardScrollFix } from '../platform';
export interface PassphrasePromptParams {
app: App;
@ -25,6 +26,7 @@ export class PassphraseModal extends Modal {
this.modalEl.addClass('rewrite-modal');
this.modalEl.addClass('rewrite-passphrase-modal');
const { contentEl } = this;
installMobileKeyboardScrollFix(contentEl);
contentEl.createEl('h2', { text: this.params.title });
if (this.params.description) {

View file

@ -1,42 +1,30 @@
import { Notice } from 'obsidian';
import type ReWritePlugin from '../main';
import { PipelineSource, PipelineStage, runPipeline } from '../pipeline';
import { isMediaRecorderAvailable, isWebSpeechAvailable, resolveActiveProfile } from '../platform';
import { isMediaRecorderAvailable, resolveActiveProfile } from '../platform';
import { Recorder } from '../recorder';
import { startWebSpeech, WebSpeechSession } from '../webspeech';
import { NoteTemplate } from '../types';
import { isProfileConfigured } from './setup-card';
import { ReWriteModal } from './modal';
export class QuickRecordController {
private recorder: Recorder | null = null;
private webSpeech: WebSpeechSession | null = null;
private timerHandle: number | null = null;
private floater: QuickRecordFloater | null = null;
private settled = false;
private startedAt = 0;
private template: NoteTemplate;
constructor(
private readonly plugin: ReWritePlugin,
template: NoteTemplate,
private readonly isWebSpeech: boolean,
private readonly onDispose: () => void,
) {
this.template = template;
}
async begin(): Promise<void> {
const { profile } = resolveActiveProfile(this.plugin.settings);
if (this.isWebSpeech) {
this.webSpeech = startWebSpeech({
language: profile.transcriptionConfig.language || undefined,
});
} else {
this.recorder = new Recorder();
await this.recorder.start(this.plugin.settings.recordingFormat);
}
this.startedAt = Date.now();
this.recorder = new Recorder();
await this.recorder.start(this.plugin.settings.recordingFormat);
this.floater = new QuickRecordFloater({
onStop: () => {
void this.finish();
@ -51,9 +39,7 @@ export class QuickRecordController {
initialTemplateName: this.template.name,
});
this.timerHandle = window.setInterval(() => {
const ms = this.isWebSpeech
? Date.now() - this.startedAt
: this.recorder?.getElapsedMs() ?? 0;
const ms = this.recorder?.getElapsedMs() ?? 0;
this.floater?.setTime(formatDuration(ms));
}, 250);
}
@ -64,8 +50,6 @@ export class QuickRecordController {
this.stopTimer();
this.recorder?.cancel();
this.recorder = null;
this.webSpeech?.cancel();
this.webSpeech = null;
this.floater?.dispose();
this.floater = null;
this.onDispose();
@ -105,11 +89,6 @@ export class QuickRecordController {
}
private async endCapture(): Promise<PipelineSource> {
if (this.isWebSpeech) {
const transcript = (await this.webSpeech?.stop()) ?? '';
this.webSpeech = null;
return { kind: 'webspeech', transcript };
}
if (!this.recorder) throw new Error('No active recording.');
const result = await this.recorder.stop();
this.recorder = null;
@ -137,6 +116,12 @@ export async function startQuickRecord(
return null;
}
if (profile.transcriptionProvider === 'none') {
new Notice('ReWrite: transcription is disabled for this profile. Opening the modal so you can paste text instead.');
new ReWriteModal(plugin.app, plugin).open();
return null;
}
if (!isProfileConfigured(profile)) {
new Notice('ReWrite: profile is not configured. Finish setup to use quick record.');
new ReWriteModal(plugin.app, plugin).open();
@ -150,19 +135,13 @@ export async function startQuickRecord(
return null;
}
const isWebSpeech = profile.transcriptionProvider === 'webspeech';
if (isWebSpeech && !isWebSpeechAvailable()) {
new Notice('Web Speech is not available here. Opening the modal instead.');
new ReWriteModal(plugin.app, plugin).open();
return null;
}
if (!isWebSpeech && !isMediaRecorderAvailable()) {
if (!isMediaRecorderAvailable()) {
new Notice('Audio recording is not supported in this environment. Opening the modal instead.');
new ReWriteModal(plugin.app, plugin).open();
return null;
}
const controller = new QuickRecordController(plugin, template, isWebSpeech, onDispose);
const controller = new QuickRecordController(plugin, template, onDispose);
try {
await controller.begin();
} catch (e) {

View file

@ -9,8 +9,8 @@ const TRANSCRIPTION_OPTIONS: Array<{ id: TranscriptionProviderID; label: string;
{ id: 'deepgram', label: 'Deepgram' },
{ id: 'revai', label: 'Rev.ai' },
{ id: 'mistral-voxtral', label: 'Mistral Voxtral' },
{ id: 'webspeech', label: 'Web Speech (browser)' },
{ id: 'whisper-local', label: 'Local whisper.cpp (desktop only)', desktopOnly: true },
{ id: 'none', label: 'None (text-only; recording disabled)' },
];
const LLM_OPTIONS: Array<{ id: LLMProviderID; label: string }> = [
@ -19,11 +19,12 @@ const LLM_OPTIONS: Array<{ id: LLMProviderID; label: string }> = [
{ id: 'openai-compatible', label: 'OpenAI-compatible (local server)' },
{ id: 'gemini', label: 'Google Gemini' },
{ id: 'mistral', label: 'Mistral' },
{ id: 'none', label: 'None (skip cleanup; insert raw text)' },
];
export function isProfileConfigured(profile: EnvironmentProfile): boolean {
const tx = profile.transcriptionProvider;
if (tx !== 'webspeech') {
if (tx !== 'none') {
if (!profile.transcriptionConfig.model) return false;
if (tx !== 'whisper-local' && !profile.transcriptionConfig.apiKey) return false;
if (tx === 'openai-compatible' && !profile.transcriptionConfig.baseUrl.trim()) return false;
@ -32,6 +33,7 @@ export function isProfileConfigured(profile: EnvironmentProfile): boolean {
}
export function isProfileConfiguredForText(profile: EnvironmentProfile): boolean {
if (profile.llmProvider === 'none') return true;
if (!profile.llmConfig.model) return false;
if (!profile.llmConfig.apiKey) return false;
if (profile.llmProvider === 'openai-compatible' && !profile.llmConfig.baseUrl.trim()) return false;
@ -75,7 +77,7 @@ export function renderSetupCard(params: SetupCardParams): void {
});
});
if (profile.transcriptionProvider !== 'webspeech') {
if (profile.transcriptionProvider !== 'none') {
new Setting(card)
.setName('Transcription model')
.setDesc(modelPlaceholderForTranscription(profile.transcriptionProvider))
@ -122,34 +124,36 @@ export function renderSetupCard(params: SetupCardParams): void {
});
});
new Setting(card)
.setName('LLM model')
.setDesc(modelPlaceholderForLLM(profile.llmProvider))
.addText((t) => {
t.setValue(profile.llmConfig.model);
t.onChange((v) => {
profile.llmConfig.model = v;
});
});
if (profile.llmProvider === 'openai-compatible') {
if (profile.llmProvider !== 'none') {
new Setting(card)
.setName('LLM base URL')
.setDesc('e.g. http://localhost:11434/v1 (Ollama) or http://localhost:1234/v1 (LM Studio)')
.setName('LLM model')
.setDesc(modelPlaceholderForLLM(profile.llmProvider))
.addText((t) => {
t.setValue(profile.llmConfig.baseUrl);
t.setValue(profile.llmConfig.model);
t.onChange((v) => {
profile.llmConfig.baseUrl = v;
profile.llmConfig.model = v;
});
});
}
renderApiKeyField(card, {
label: 'LLM API key',
placeholder: 'Saved securely on this device',
getValue: () => profile.llmConfig.apiKey,
setValue: (v) => { profile.llmConfig.apiKey = v; },
});
if (profile.llmProvider === 'openai-compatible') {
new Setting(card)
.setName('LLM base URL')
.setDesc('e.g. http://localhost:11434/v1 (Ollama) or http://localhost:1234/v1 (LM Studio)')
.addText((t) => {
t.setValue(profile.llmConfig.baseUrl);
t.onChange((v) => {
profile.llmConfig.baseUrl = v;
});
});
}
renderApiKeyField(card, {
label: 'LLM API key',
placeholder: 'Saved securely on this device',
getValue: () => profile.llmConfig.apiKey,
setValue: (v) => { profile.llmConfig.apiKey = v; },
});
}
const actions = card.createDiv({ cls: 'rewrite-setup-actions' });
const saveBtn = actions.createEl('button', { text: 'Save and continue', cls: 'mod-cta' });
@ -180,6 +184,8 @@ function renderApiKeyField(container: HTMLElement, field: KeyField): void {
function modelPlaceholderForTranscription(id: TranscriptionProviderID): string {
switch (id) {
case 'none':
return '';
case 'openai':
return 'e.g. whisper-1';
case 'groq':
@ -196,13 +202,13 @@ function modelPlaceholderForTranscription(id: TranscriptionProviderID): string {
return 'Whichever model your local server exposes';
case 'whisper-local':
return 'Any value works; the loaded model is set at server start';
case 'webspeech':
return '';
}
}
function modelPlaceholderForLLM(id: LLMProviderID): string {
switch (id) {
case 'none':
return '';
case 'anthropic':
return 'e.g. claude-sonnet-4-5 or claude-haiku-4-5-20251001';
case 'openai':

View file

@ -1,141 +0,0 @@
interface SpeechRecognitionAlternativeLike {
transcript: string;
}
interface SpeechRecognitionResultLike {
0: SpeechRecognitionAlternativeLike;
length: number;
isFinal: boolean;
}
interface SpeechRecognitionResultListLike {
length: number;
[index: number]: SpeechRecognitionResultLike;
}
interface SpeechRecognitionEventLike {
results: SpeechRecognitionResultListLike;
resultIndex: number;
}
interface SpeechRecognitionErrorEventLike {
error?: string;
}
interface SpeechRecognitionLike {
continuous: boolean;
interimResults: boolean;
lang: string;
start(): void;
stop(): void;
abort(): void;
addEventListener(type: 'result', listener: (e: SpeechRecognitionEventLike) => void): void;
addEventListener(type: 'error', listener: (e: SpeechRecognitionErrorEventLike) => void): void;
addEventListener(type: 'end', listener: () => void): void;
}
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
function getConstructor(): SpeechRecognitionCtor | null {
const w = window as unknown as {
SpeechRecognition?: SpeechRecognitionCtor;
webkitSpeechRecognition?: SpeechRecognitionCtor;
};
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
}
export interface WebSpeechSession {
stop(): Promise<string>;
cancel(): void;
}
export interface StartWebSpeechOptions {
language?: string;
onUpdate?: (combinedTranscript: string) => void;
}
export function startWebSpeech(options: StartWebSpeechOptions = {}): WebSpeechSession {
const Ctor = getConstructor();
if (!Ctor) {
throw new Error('Web Speech API is not available in this environment');
}
const recognition = new Ctor();
recognition.continuous = true;
recognition.interimResults = true;
if (options.language) recognition.lang = options.language;
let finalTranscript = '';
let interimTranscript = '';
let resolveStop: ((text: string) => void) | null = null;
let rejectStop: ((err: Error) => void) | null = null;
let terminated = false;
recognition.addEventListener('result', (event: SpeechRecognitionEventLike) => {
let interim = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
if (!result || result.length === 0) continue;
const transcript = result[0].transcript;
if (result.isFinal) {
finalTranscript += transcript;
} else {
interim += transcript;
}
}
interimTranscript = interim;
options.onUpdate?.((finalTranscript + interim).trim());
});
recognition.addEventListener('error', (event: SpeechRecognitionErrorEventLike) => {
if (terminated) return;
terminated = true;
const err = new Error(`Web Speech error: ${event.error ?? 'unknown'}`);
if (rejectStop) {
rejectStop(err);
rejectStop = null;
resolveStop = null;
}
});
recognition.addEventListener('end', () => {
const text = (finalTranscript + interimTranscript).trim();
if (resolveStop) {
resolveStop(text);
resolveStop = null;
rejectStop = null;
}
});
recognition.start();
return {
stop(): Promise<string> {
return new Promise<string>((resolve, reject) => {
if (terminated) {
resolve((finalTranscript + interimTranscript).trim());
return;
}
resolveStop = resolve;
rejectStop = reject;
try {
recognition.stop();
} catch {
terminated = true;
resolveStop = null;
rejectStop = null;
resolve((finalTranscript + interimTranscript).trim());
}
});
},
cancel(): void {
terminated = true;
resolveStop = null;
rejectStop = null;
try {
recognition.abort();
} catch {
// ignore
}
},
};
}

View file

@ -145,6 +145,36 @@
margin-bottom: 8px;
}
.rewrite-settings .rewrite-profile-section {
margin-bottom: 16px;
}
.rewrite-settings .rewrite-profile-section.is-active-profile {
border-left: 3px solid var(--interactive-accent);
padding-left: 12px;
margin-left: -15px;
}
.rewrite-settings .rewrite-profile-active-badge {
display: inline-block;
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-radius: 4px;
font-size: var(--font-ui-smaller);
padding: 1px 6px;
margin-left: 8px;
vertical-align: middle;
font-weight: normal;
}
.rewrite-settings .rewrite-profile-collapsed > summary {
cursor: pointer;
user-select: none;
color: var(--text-muted);
font-size: var(--font-ui-small);
padding: 4px 0;
}
/* Quick record floating mini-UI */
.rewrite-quick-floater {