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
@@ -87,6 +87,34 @@ final class ChunkedUtterancePipelineTests: XCTestCase {
XCTAssertFalse(success.text.isEmpty)
XCTAssertEqual(success.chunkWarnings.count, 1)
}
func testPipelineRetranscribesShortFinalChunkWithPriorOverlap() async {
let config = FlowUtteranceChunkConfig(
maxChunkDurationSeconds: 0.05,
overlapDurationSeconds: 10,
pauseExtensionMaxSeconds: 0,
pauseRMSThreshold: 0.02,
minFinalChunkDurationSeconds: 0.05,
sampleRate: 1_000
)
let asr = ShortFinalMergeStubASR()
let pipeline = ChunkedUtterancePipeline(
asr: asr,
locale: Locale(identifier: "zh-Hans"),
config: config
)
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 20), sampleRate: 1_000))
continuation.finish()
let outcome = await pipeline.transcribe(stream: stream) { _ in }
guard case .success(let success) = outcome else {
return XCTFail("expected success, got \(outcome)")
}
XCTAssertTrue(success.text.contains("merged"))
}
}
private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
@@ -114,3 +142,32 @@ private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
return .success("seg\(samples.count)")
}
}
private struct ShortFinalMergeStubASR: ASRService, @unchecked Sendable {
private let callIndex = OSAllocatedUnfairLock(initialState: 0)
func transcribe(
stream: AsyncStream<AudioBufferSnapshot>,
locale: Locale
) -> AsyncStream<ASREvent> {
AsyncStream { $0.finish() }
}
func cancel() {}
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
_ = locale
let current = callIndex.withLock { state in
let value = state
state += 1
return value
}
if current == 0 {
return .success("head")
}
if samples.count > 20 {
return .success("merged-tail")
}
return .success("short")
}
}