fix(dictation): serialize audio flushes so stop awaits in-flight IPC

A timer-tick flushAudioBatch() whose native IPC is still awaiting could be
left un-awaited by stopRecording()/cleanup(), which only awaited the flush
they kicked off themselves. Stopping in that window let stop_dictation fire
before an earlier flush's chunks were enqueued native-side, dropping late
chunks and losing transcript words.

Serialize flushes through a flushChainRef promise tail: each flush drains the
batch synchronously (distinct FIFO payload) then chains its IPC send after any
prior in-flight flush. stopRecording()/cleanup() await the chain tail (the
empty-batch path now returns the tail), guaranteeing every started flush is
fully enqueued before stop. Chain is reset per session in startRecording.

Bump MessageComposer.tsx file-size override (+15) for the dictation wiring
that pushed it 1033->1049, matching the existing load-bearing-growth pattern.
This commit is contained in:
npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
2026-07-11 16:20:29 +01:00
parent 25339523ec
commit 5d847e5767
2 changed files with 30 additions and 5 deletions
+4 -1
View File
@@ -339,7 +339,10 @@ const overrides = new Map([
// +3: onLinkShortcutRef wiring (ref decl + editor option + assignment) for
// the ⌘K link-editor shortcut, mirroring the existing onEditLinkRef
// pattern. Queued to split with the rest of this list.
["src/features/messages/ui/MessageComposer.tsx", 1036],
// +15 local-dictation integration: useLocalDictation wiring (mic toggle,
// transcript-append handler, recording/transcribing state, stop-on-send/edit
// guards). Load-bearing feature growth; queued to split with the rest.
["src/features/messages/ui/MessageComposer.tsx", 1052],
// global-agent-config: model-tuning section (BuzzAgentModelTuningFields via
// EditAgentAdvancedFields) + providerValid gate + effectiveProvider derivation
// + globalProvider threading into getPersonaProviderOptions. All load-bearing
@@ -84,6 +84,10 @@ export function useLocalDictation({
const workletRef = useRef<AudioWorkletNode | null>(null);
const batchTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const audioBatchRef = useRef<Float32Array[]>([]);
// Tail of the serialized flush chain. Each flush appends its IPC work to
// this promise so flushes run strictly one-after-another; awaiting the tail
// guarantees every already-started flush's chunks are enqueued native-side.
const flushChainRef = useRef<Promise<void>>(Promise.resolve());
const unlistenTranscriptRef = useRef<UnlistenFn | null>(null);
const unlistenStateRef = useRef<UnlistenFn | null>(null);
const onRecordingStartRef = useRef(onRecordingStart);
@@ -137,10 +141,17 @@ export function useLocalDictation({
}, []);
/** 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). */
* that resolves once the IPC call completes (or immediately if nothing to flush).
*
* Flushes are serialized through `flushChainRef`: the batch is drained
* synchronously here (so each flush owns a distinct set of samples in FIFO
* order), but the actual IPC send is chained after any prior in-flight
* flush. This keeps chunks strictly ordered and lets `stopRecording`/
* `cleanup` await the chain tail so an earlier timer-tick flush can't still
* be enqueuing chunks when `stop_dictation` fires. */
const flushAudioBatch = useCallback((): Promise<void> => {
const batch = audioBatchRef.current;
if (batch.length === 0) return Promise.resolve();
if (batch.length === 0) return flushChainRef.current;
// Tag this flush with the session that owns the buffered audio. Captured
// once here so every chunk of this batch carries the same session; native
@@ -148,7 +159,10 @@ export function useLocalDictation({
// flush from a just-stopped session can't leak into a newer session.
const session = nativeSessionRef.current;
// Calculate total byte length and merge into a single buffer.
// Calculate total byte length and merge into a single buffer. Drain the
// batch synchronously so a concurrent timer tick can't grab the same
// samples — the merged buffer captured here is this flush's exclusive
// payload.
let totalSamples = 0;
for (const chunk of batch) {
totalSamples += chunk.length;
@@ -193,7 +207,12 @@ export function useLocalDictation({
await invokeRawBinary("push_dictation_audio", payload).catch(() => {});
}
};
return sendChunks();
// Chain this flush's IPC work after any prior in-flight flush so chunks
// stay strictly ordered and awaiting the tail drains everything.
const chained = flushChainRef.current.then(sendChunks);
flushChainRef.current = chained;
return chained;
}, []);
const cleanup = useCallback(() => {
@@ -267,6 +286,9 @@ export function useLocalDictation({
// Reset any leftover audio buffer so a stale batch from a prior session
// can't be flushed into this new session/draft.
audioBatchRef.current = [];
// Reset the flush chain so this session's flushes don't chain behind a
// prior session's (already-settled) tail.
flushChainRef.current = Promise.resolve();
setIsStarting(true);
onRecordingStartRef.current?.();