From 87492aab89dabebad782d0cbc25030777933f849 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 6 Jul 2026 17:20:26 +0100 Subject: [PATCH] fix(dictation): guard cross-composer cancel, stop engine on unmount, await flush before stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 3 new review comments: 1. **Avoid cancelling another composer's dictation** — the draftKey effect now checks isRecordingRef before calling cancelRecording(), so switching channels in one composer won't kill a recording in another. 2. **Stop native dictation on unmount** — cleanup() now calls invoke('stop_dictation') so the native SttEngine doesn't linger when the hook unmounts mid-recording (navigation, composer close). 3. **Await final audio flush before stopping STT** — stopRecording() and cleanup() now await the flushAudioBatch() promise before invoking stop_dictation, ensuring the last batch of audio arrives at the native engine before it shuts down and flushes its speech buffer. --- .../dictation/hooks/useComposerDictation.ts | 8 ++++- .../dictation/hooks/useLocalDictation.ts | 31 +++++++++++-------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/desktop/src/features/dictation/hooks/useComposerDictation.ts b/desktop/src/features/dictation/hooks/useComposerDictation.ts index 564db7fe3..709662ccb 100644 --- a/desktop/src/features/dictation/hooks/useComposerDictation.ts +++ b/desktop/src/features/dictation/hooks/useComposerDictation.ts @@ -89,9 +89,15 @@ export function useComposerDictation({ // Cancel dictation when the channel/thread changes so that transcript events // from a stale local STT session don't leak into the wrong draft. + // Only cancel if this instance is actually recording — avoids killing another + // composer's session since the native engine is a singleton. + const isRecordingRef = useRef(false); + isRecordingRef.current = dictation.isRecording || dictation.isStarting; // biome-ignore lint/correctness/useExhaustiveDependencies: draftKey is the sole trigger useEffect(() => { - dictation.cancelRecording(); + if (isRecordingRef.current) { + dictation.cancelRecording(); + } }, [draftKey]); // Auto-cancel dictation when the composer becomes disabled mid-recording diff --git a/desktop/src/features/dictation/hooks/useLocalDictation.ts b/desktop/src/features/dictation/hooks/useLocalDictation.ts index 0d992b92b..b5a938fdf 100644 --- a/desktop/src/features/dictation/hooks/useLocalDictation.ts +++ b/desktop/src/features/dictation/hooks/useLocalDictation.ts @@ -111,10 +111,11 @@ export function useLocalDictation({ }; }, []); - /** Flush accumulated audio batch to the native STT engine. */ - const flushAudioBatch = useCallback(() => { + /** Flush accumulated audio batch to the native STT engine. Returns a promise + * that resolves once the IPC call completes (or immediately if nothing to flush). */ + const flushAudioBatch = useCallback((): Promise => { const batch = audioBatchRef.current; - if (batch.length === 0) return; + if (batch.length === 0) return Promise.resolve(); // Calculate total byte length and merge into a single buffer. let totalSamples = 0; @@ -134,12 +135,16 @@ export function useLocalDictation({ merged.byteOffset, merged.byteLength, ); - invokeRawBinary("push_dictation_audio", bytes).catch(() => {}); + return invokeRawBinary("push_dictation_audio", bytes) + .then(() => {}) + .catch(() => {}); }, []); const cleanup = useCallback(() => { - // Flush any remaining audio before teardown. - flushAudioBatch(); + // Flush any remaining audio before teardown, then stop the native engine. + void flushAudioBatch().then(() => { + invoke("stop_dictation").catch(() => {}); + }); // Stop batch timer. if (batchTimerRef.current) { clearInterval(batchTimerRef.current); @@ -307,16 +312,12 @@ export function useLocalDictation({ }, [cleanup, flushAudioBatch, isEnabled, isRecording, isStarting]); const stopRecording = useCallback(() => { - // Flush remaining audio so the native engine can transcribe the tail. - flushAudioBatch(); - // Stop batch timer. + // Stop batch timer immediately. if (batchTimerRef.current) { clearInterval(batchTimerRef.current); batchTimerRef.current = null; } - // Stop mic and audio pipeline immediately so the user gets visual feedback, - // but keep isTranscribing=true until the native `stopped` event arrives - // (which fires only after the final transcript has been forwarded). + // Stop mic and audio pipeline immediately so the user gets visual feedback. if (streamRef.current) { for (const track of streamRef.current.getTracks()) { track.stop(); @@ -331,9 +332,13 @@ export function useLocalDictation({ void audioContextRef.current.close(); audioContextRef.current = null; } - invoke("stop_dictation").catch(() => {}); setIsRecording(false); + // Flush remaining audio and THEN stop the native engine, ensuring the + // final batch arrives before the engine shuts down and flushes its buffer. // isTranscribing stays true — cleared when `dictation-state: stopped` arrives. + void flushAudioBatch().then(() => { + invoke("stop_dictation").catch(() => {}); + }); }, [flushAudioBatch]); const cancelRecording = useCallback(() => {