fix(dictation): tear down capture on external stop + release PTT on effect cleanup

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.
This commit is contained in:
npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
2026-07-11 16:20:31 +01:00
parent 66733800aa
commit 3fa923dc88
2 changed files with 36 additions and 0 deletions
+7
View File
@@ -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);
@@ -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();