From c1b160bbe560ae42b64f49582b49bf34c06d8a46 Mon Sep 17 00:00:00 2001 From: WiseGuru <42100212+WiseGuru@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:09:21 -0700 Subject: [PATCH] enable screenwake when recording --- CLAUDE.md | 1 + src/recorder.ts | 79 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index aae9b46..2821a24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,6 +289,7 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma - **Stop semantics depend on ownership.** Spawned: `child.kill()` via the live ChildProcess handle. Adopted: `process.kill(pid, signal)` since we only have the PID, then clear the sidecar. External: `stop()` throws "not started by ReWrite — stop it via OS tools." (the settings-tab button is disabled with a tooltip, the status-bar click shows a Notice, the `stop-whisper-host` command is hidden via `checkCallback`). This preserves the "never kill a process we didn't start" invariant — the sidecar is proof we started it. - **`onunload` stops the whisper-host fire-and-forget.** `void this.whisperHost?.stop()` — Obsidian's `Plugin.onunload` signature is `() => void`, so we can't await. Stop() sends SIGTERM with a 3 s SIGKILL fallback, but the unload sequence may complete before that. Child processes do NOT auto-die with the parent on Linux (they reparent to init), on Windows (orphaned but still running), or reliably on macOS (SIGTERM may not land before Obsidian exits). The probe/adopt flow above is what closes this hole on next launch; don't try to make unload async. - **Audio persistence runs before transcription**, not after, so the user keeps the recording even if transcription fails. [src/audio-persist.ts](src/audio-persist.ts) catches its own errors and emits a `Notice`; the pipeline always continues to the transcribe stage even when persistence throws. Cancel paths in [src/ui/modal.ts](src/ui/modal.ts) and [src/ui/quick-record.ts](src/ui/quick-record.ts) call `recorder.cancel()` before `runPipeline()`, so no orphan file is written on cancel. The `![[]]` embed is prepended to the cleaned output unconditionally when an audio file was saved, regardless of insert mode. The reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) skips persistence by passing `sourcePath` on the `audio` source variant; the embed prepend still runs, reusing the existing vault path so reprocessed output links back to the original file. +- **A screen wake lock is held for the duration of active recording.** Android (and iOS) suspend the Obsidian Capacitor WebView when the screen sleeps, which kills `MediaRecorder` capture mid-recording. [src/recorder.ts](src/recorder.ts) requests `navigator.wakeLock.request('screen')` in `start()` (and re-acquires in `resume()`), releases it in `pause()`, and tears it down via `stopWakeLock()` from the shared `releaseStream()` (so both `stop()` and `cancel()` cover it). The OS auto-releases a screen wake lock whenever the document becomes hidden, so a `visibilitychange` listener (registered in `startWakeLock`, removed in `stopWakeLock`) re-requests on the next `visible` transition while `state === 'recording'`. The acquire is best-effort and degrades silently: where the Wake Lock API is absent (older WebView, desktop builds, insecure context) or denied (`NotAllowedError`), recording proceeds without it. The API is reached through a narrow local `WakeLockLike` / `WakeLockSentinelLike` interface + `getWakeLock()` cast (it is not in every TS DOM lib version), mirroring the `hotkeyManager` pattern. Cost: the screen stays lit while recording (a partial CPU-only wake lock would need native code Obsidian doesn't expose to plugins). A `stop`/`cancel` that races ahead of the async `request('screen')` is handled by re-checking `state === 'recording'` before retaining the sentinel. - **Live silence detection is a Web Audio tap on the recording stream.** [src/recorder.ts](src/recorder.ts) builds an `AnalyserNode` off the mic `MediaStream` in `start()` (no connection to `destination`, so no monitoring feedback) and samples peak amplitude every 100 ms via a `window.setInterval`. It exposes `getInputLevel()` (0..1 peak), `hasDetectedSound()`, and `getSilentMs()` (ms of continuous silence; returns 0 while paused / stopped or when the analyser could not be created, so the UI never warns when there is nothing to listen to). `resume()` resets the silence baseline so the paused gap is not counted. Both recording UIs poll `getSilentMs()` in their existing 250 ms timer loop and show a "No audio detected" warning past `SILENCE_WARNING_MS` (3 s): the modal's Record tab (`.rewrite-silence-warning`, [src/ui/modal.ts](src/ui/modal.ts)) and the Quick Record floater (`.rewrite-quick-silence-warning` below the controls row, [src/ui/quick-record.ts](src/ui/quick-record.ts) `setSilenceWarning`, hidden while busy). The monitor is torn down in `releaseStream()` (called by `stop()`/`cancel()`); any setup failure (no `AudioContext`, e.g. a stripped environment) degrades to "monitoring unavailable" and the recording proceeds without a warning. The threshold `SILENCE_LEVEL_THRESHOLD = 0.015` clears even quiet speech but flags a muted or dead mic. - **Wake-name extraction is regex-only, off by default.** [src/wake-name.ts](src/wake-name.ts) requires `,` (vocative comma) to fire, captures up to the next sentence terminator or next name occurrence, and drops filler matches ("never mind", "scratch that", short tokens). It runs on ALL pipeline sources, including `paste` and `text`. The extracted instructions are appended to the LLM system prompt as a numbered `## Ad-hoc instructions` block, prefaced by `plugin.assistantPrompt` (loaded from `GlobalSettings.assistantPromptPath`); when the file is missing or empty, `DEFAULT_ASSISTANT_PROMPT` from [src/assistant-prompt.ts](src/assistant-prompt.ts) is used as the fallback so behavior is identical to the previous hardcoded textarea. Both the OpenAI and Anthropic adapters route this into the API's system slot. Whisper transcription homophones ("Scribner", "Scrivner") are not fuzzy-matched in v1; document the limitation if a user reports misses. - **Known nouns frontmatter is NOT sent to the LLM.** The vault file at `GlobalSettings.knownNounsPath` uses YAML frontmatter purely for human-readable guidance (token-cost warning, format hint). Only the body lines are parsed via `loadKnownNounsFromFile` and injected by `buildKnownNounsSystemPromptSection`. If a future change opts frontmatter in, it should be a per-vault opt-in setting, not the default. The body parser treats `#` lines and blank lines as ignored; an entry can be either bare canonical (`Anthropic`) or canonical + misheard alternates (`Hoxhunt: hawks hunt, hocks hunt`). diff --git a/src/recorder.ts b/src/recorder.ts index 0f1fae2..f2dc366 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -28,6 +28,20 @@ const SILENCE_LEVEL_THRESHOLD = 0.015; // How often the level monitor samples the analyser while recording. const LEVEL_SAMPLE_INTERVAL_MS = 100; +// navigator.wakeLock is not present in every TypeScript DOM lib version (and not in +// every WebView), so we declare the narrow slice we use and access it via a cast. +interface WakeLockSentinelLike { + readonly released: boolean; + release(): Promise; + addEventListener(type: 'release', listener: () => void): void; +} +interface WakeLockLike { + request(type: 'screen'): Promise; +} +function getWakeLock(): WakeLockLike | undefined { + return (navigator as unknown as { wakeLock?: WakeLockLike }).wakeLock; +} + export function getBestMimeType(preference: RecordingFormatPreference): string { if (typeof MediaRecorder === 'undefined') return ''; const candidates = preference === 'mp4' ? MP4_FIRST : WEBM_FIRST; @@ -56,6 +70,11 @@ export class Recorder { private lastSoundAt = 0; private soundDetected = false; + // Screen wake lock: holds the device awake while recording so Android (and iOS) do + // not suspend the WebView when the screen would otherwise sleep, which kills capture. + private wakeLock: WakeLockSentinelLike | null = null; + private visibilityHandler: (() => void) | null = null; + getState(): RecorderState { return this.state; } @@ -118,6 +137,7 @@ export class Recorder { this.accumulatedMs = 0; this.state = 'recording'; this.startLevelMonitor(stream); + this.startWakeLock(); recorder.start(); } @@ -129,6 +149,8 @@ export class Recorder { this.accumulatedMs += Date.now() - this.startedAt; this.mediaRecorder.pause(); this.state = 'paused'; + // Paused recording can't lose data, so let the screen sleep until resume. + this.releaseWakeLock(); } resume(): void { @@ -141,6 +163,7 @@ export class Recorder { // Don't count the paused gap as silence. this.lastSoundAt = Date.now(); this.state = 'recording'; + void this.acquireWakeLock(); } async stop(): Promise { @@ -183,12 +206,68 @@ export class Recorder { private releaseStream(): void { this.stopLevelMonitor(); + this.stopWakeLock(); if (this.stream) { for (const track of this.stream.getTracks()) track.stop(); this.stream = null; } } + /** + * Begin holding a screen wake lock and register the visibility listener that + * re-acquires it. The OS auto-releases a screen wake lock whenever the document + * becomes hidden, so we re-request on the next 'visible' transition while still + * recording. Best-effort: where the Wake Lock API is missing (older WebView, + * desktop builds, insecure context) recording proceeds without it. + */ + private startWakeLock(): void { + void this.acquireWakeLock(); + if (this.visibilityHandler) return; + this.visibilityHandler = () => { + if (document.visibilityState === 'visible' && this.state === 'recording' && !this.wakeLock) { + void this.acquireWakeLock(); + } + }; + document.addEventListener('visibilitychange', this.visibilityHandler); + } + + private async acquireWakeLock(): Promise { + const wl = getWakeLock(); + if (!wl) return; + if (this.wakeLock && !this.wakeLock.released) return; + try { + const sentinel = await wl.request('screen'); + sentinel.addEventListener('release', () => { + if (this.wakeLock === sentinel) this.wakeLock = null; + }); + // A stop/cancel may have raced ahead of this async request; if so, drop it. + if (this.state === 'recording') { + this.wakeLock = sentinel; + } else { + void sentinel.release().catch(() => { /* best effort */ }); + } + } catch { + // NotAllowedError (denied / not user-active / insecure context): proceed without. + this.wakeLock = null; + } + } + + private releaseWakeLock(): void { + const sentinel = this.wakeLock; + this.wakeLock = null; + if (sentinel && !sentinel.released) { + void sentinel.release().catch(() => { /* best effort */ }); + } + } + + private stopWakeLock(): void { + this.releaseWakeLock(); + if (this.visibilityHandler) { + document.removeEventListener('visibilitychange', this.visibilityHandler); + this.visibilityHandler = null; + } + } + private startLevelMonitor(stream: MediaStream): void { this.currentLevel = 0; this.soundDetected = false;