fix(dictation): guard cross-composer cancel, stop engine on unmount, await flush before stop

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.
This commit is contained in:
klopez4212
2026-07-11 16:19:19 +01:00
parent 014645be87
commit 87492aab89
2 changed files with 25 additions and 14 deletions
@@ -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
@@ -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<void> => {
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(() => {