mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
parent
66733800aa
commit
3fa923dc88
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user