fix(flow): drain trailing audio and harden chunked ASR pipeline

- P0/P1: tail-drain state machine in FlowContinuousCapture with converter
  flush; FlowSessionManager awaits drain before finalize
- P2: FlowCaptureTailDrain policy/tracker, pipeline diagnostics, unit tests
- P3: short final chunk merged re-transcription in ChunkedUtterancePipeline
- P4: UtteranceTranscriptStitcher composedSafely fallback; preview path parity
  in LiveDictationController

Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-07-06 05:13:55 +00:00
parent 537a68552a
commit 8b105430e3
14 changed files with 690 additions and 47 deletions
@@ -11,4 +11,8 @@ enum FlowDiagnostics {
static func log(_ message: String) {
OSGLog.flow.info("\(message, privacy: .public)")
}
static func logDrain(_ report: FlowCaptureDrainReport) {
FlowPipelineDiagnostics.logDrain(report)
}
}
@@ -485,20 +485,20 @@ final class FlowSessionManager: ObservableObject {
// Close the mic gate first, then mark processing before dropping the
// recording flag so the poll loop cannot start a second utterance.
capture.endUtterance()
FlowSessionBridge.setRecordingState(.processing)
isUtteranceRecording = false
isUtteranceProcessing = true
// Do NOT cancel `asrTask` or `asr` the preview pipeline relies on
// the consumer staying alive until `.final` lands (see
// `PreviewASRControllerStateTests`).
// Do NOT cancel `asrTask` or `asr` drain trailing PCM, then finalize.
finalizeTask?.cancel()
finalizeTask = Task { @MainActor [weak self] in
await self?.finalizeUtterance()
guard let self else { return }
let drainReport = await self.capture.endUtteranceAndDrain()
FlowDiagnostics.logDrain(drainReport)
await self.finalizeUtterance()
}
debug("utterance stopped, finalizing")
debug("utterance stopped, draining tail")
}
private func abortUtterance() {