()
| 320 | }, [setVoiceState]) |
| 321 | |
| 322 | function finishRecording(): void { |
| 323 | logForDebugging( |
| 324 | '[voice] finishRecording: stopping recording, transitioning to processing', |
| 325 | ) |
| 326 | // Session ending — stale any in-flight attempt so its late onError |
| 327 | // (conn 2 responding after user released key) doesn't double-fire on |
| 328 | // top of the "check network" message below. |
| 329 | attemptGenRef.current++ |
| 330 | // Capture focusTriggered BEFORE clearing it — needed as an event dimension |
| 331 | // so BigQuery can filter out passive focus-mode auto-recordings (user focused |
| 332 | // terminal without speaking → ambient noise sets hadAudioSignal=true → false |
| 333 | // silent-drop signature). focusFlushedCharsRef fixes transcriptChars accuracy |
| 334 | // for sessions WITH speech; focusTriggered enables filtering sessions WITHOUT. |
| 335 | const focusTriggered = focusTriggeredRef.current |
| 336 | focusTriggeredRef.current = false |
| 337 | updateState('processing') |
| 338 | voiceModule?.stopRecording() |
| 339 | // Capture duration BEFORE the finalize round-trip so that the WebSocket |
| 340 | // wait time is not included (otherwise a quick tap looks like > 2s). |
| 341 | // All ref-backed values are captured here, BEFORE the async boundary — |
| 342 | // a keypress during the finalize wait can start a new session and reset |
| 343 | // these refs (e.g. focusFlushedCharsRef = 0 in startRecordingSession), |
| 344 | // reproducing the silent-drop false-positive this ref exists to prevent. |
| 345 | const recordingDurationMs = Date.now() - recordingStartRef.current |
| 346 | const hadAudioSignal = hasAudioSignalRef.current |
| 347 | const retried = retryUsedRef.current |
| 348 | const focusFlushedChars = focusFlushedCharsRef.current |
| 349 | // wsConnected distinguishes "backend received audio but dropped it" (the |
| 350 | // bug backend PR #287008 fixes) from "WS handshake never completed" — |
| 351 | // in the latter case audio is still in audioBuffer, never reached the |
| 352 | // server, but hasAudioSignalRef is already true from ambient noise. |
| 353 | const wsConnected = everConnectedRef.current |
| 354 | // Capture generation BEFORE the .then() — if a new session starts during |
| 355 | // the finalize wait, sessionGenRef has already advanced by the time the |
| 356 | // continuation runs, so capturing inside the .then() would yield the new |
| 357 | // session's gen and every staleness check would be a no-op. |
| 358 | const myGen = sessionGenRef.current |
| 359 | const isStale = () => sessionGenRef.current !== myGen |
| 360 | logForDebugging('[voice] Recording stopped') |
| 361 | |
| 362 | // Send finalize and wait for the WebSocket to close before reading the |
| 363 | // accumulated transcript. The close handler promotes any unreported |
| 364 | // interim text to final, so we must wait for it to fire. |
| 365 | const finalizePromise: Promise<FinalizeSource | undefined> = |
| 366 | connectionRef.current |
| 367 | ? connectionRef.current.finalize() |
| 368 | : Promise.resolve(undefined) |
| 369 | |
| 370 | void finalizePromise |
| 371 | .then(async finalizeSource => { |
| 372 | if (isStale()) return |
| 373 | // Silent-drop replay: when the server accepted audio (wsConnected), |
| 374 | // the mic captured real signal (hadAudioSignal), but finalize timed |
| 375 | // out with zero transcript — the ~1% session-sticky CE-pod bug. |
| 376 | // Replay the buffered audio on a fresh connection once. A 250ms |
| 377 | // backoff clears the same-pod rapid-reconnect race (same gap as the |
| 378 | // early-error retry path below). |
| 379 | if ( |
no test coverage detected