From 3fa923dc88e07b178daa5cce6c67a54904d3e1e9 Mon Sep 17 00:00:00 2001 From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> Date: Fri, 10 Jul 2026 17:15:13 +0100 Subject: [PATCH] fix(dictation): tear down capture on external stop + release PTT on effect cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Codex P2 findings on PR #1511: - useLocalDictation.ts: the native STT engine is a singleton. When another mounted composer calls start_dictation, it stops this composer's session, so this hook can receive dictation-state:stopped without its own stopRecording()/cleanup() having run. The stopped branch only flipped React flags and removed listeners, leaving streamRef/workletRef/batchTimerRef/ audioContextRef alive — the old composer kept the mic open and pushed stale audio until unmount. Tear down the local capture pipeline in the stopped branch (without re-invoking stop_dictation, since native already stopped). - AppShell.tsx: when settingsOpen (or another dep) flips while Cmd+D is held, the effect cleanup removed listeners but never released the held key — the keyup listener was gone and the effect-local dictationKeyHeld was lost, so the mic kept recording. Call releaseDictationKey() in the cleanup before unregistering. Deps are referentially stable, so this only fires on the settings toggle, not on normal renders mid-hold. --- desktop/src/app/AppShell.tsx | 7 +++++ .../dictation/hooks/useLocalDictation.ts | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 688ae69aa..6a53a6af3 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -643,6 +643,13 @@ export function AppShell() { window.addEventListener("blur", releaseDictationKey); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { + // Release a held ⌘D before unregistering. If `settingsOpen` (or another + // dep) flips while the key is down, this cleanup runs and the next effect + // pass returns early (Settings) or rebinds fresh — either way the `keyup` + // listener is gone and the effect-local `dictationKeyHeld` is lost, so the + // release would never fire and the mic would keep recording. Dispatch it + // here first. + releaseDictationKey(); window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("blur", releaseDictationKey); diff --git a/desktop/src/features/dictation/hooks/useLocalDictation.ts b/desktop/src/features/dictation/hooks/useLocalDictation.ts index f25a4e05d..979862a53 100644 --- a/desktop/src/features/dictation/hooks/useLocalDictation.ts +++ b/desktop/src/features/dictation/hooks/useLocalDictation.ts @@ -351,6 +351,35 @@ export function useLocalDictation({ if (state === "stopped") { setIsRecording(false); setIsTranscribing(false); + // Tear down this instance's local capture pipeline. The native + // engine is a singleton: when another mounted composer calls + // `start_dictation`, it stops this session's engine, so we can + // receive `stopped` without our own `stopRecording()`/`cleanup()` + // having run. Without tearing down here, `streamRef`/`workletRef`/ + // `batchTimerRef` stay alive and this composer keeps the mic open, + // pushing stale audio, until it unmounts. Don't re-invoke + // `stop_dictation` — the native side already stopped (that's why + // this event fired). + if (batchTimerRef.current) { + clearInterval(batchTimerRef.current); + batchTimerRef.current = null; + } + if (streamRef.current) { + for (const track of streamRef.current.getTracks()) { + track.stop(); + } + streamRef.current = null; + } + if (workletRef.current) { + workletRef.current.port.onmessage = null; + workletRef.current.disconnect(); + workletRef.current = null; + } + audioBatchRef.current = []; + if (audioContextRef.current) { + void audioContextRef.current.close(); + audioContextRef.current = null; + } // Clean up event listeners now that the session is fully done. if (unlistenTranscriptRef.current) { unlistenTranscriptRef.current();