fix(flow): improve tail capture and final chunk ASR recovery

- Add FlowUtteranceEndCoordinator with 350ms silence drain and 150ms post-roll
- Extend FinalChunkRecovery for short/empty final chunks in chunked pipeline
- Snapshot partial at mic stop and guard final transcript in FlowSessionManager
- Unify tail drain presets (iosFlow/macMLX) and expand diagnostics

Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-07-26 10:13:40 +00:00
parent e6f99d2744
commit c7ee891a90
16 changed files with 485 additions and 42 deletions
@@ -115,6 +115,34 @@ final class ChunkedUtterancePipelineTests: XCTestCase {
}
XCTAssertTrue(success.text.contains("merged"))
}
func testPipelineRetriesEmptyFinalChunkWithOverlap() async {
let config = FlowUtteranceChunkConfig(
maxChunkDurationSeconds: 0.05,
overlapDurationSeconds: 10,
pauseExtensionMaxSeconds: 0,
pauseRMSThreshold: 0.02,
minFinalChunkDurationSeconds: 0.05,
sampleRate: 1_000
)
let asr = EmptyFinalRetryStubASR()
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: 80), 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("recovered-tail"))
}
}
private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
@@ -171,3 +199,32 @@ private struct ShortFinalMergeStubASR: ASRService, @unchecked Sendable {
return .success("short")
}
}
private struct EmptyFinalRetryStubASR: 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 current == 1 {
return .success("")
}
return .success("recovered-tail")
}
}
@@ -0,0 +1,74 @@
// FinalChunkRecoveryTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class FinalChunkRecoveryTests: XCTestCase {
private let config = FlowUtteranceChunkConfig(
maxChunkDurationSeconds: 5.0,
overlapDurationSeconds: 0.5,
pauseExtensionMaxSeconds: 2,
pauseRMSThreshold: 0.015,
minFinalChunkDurationSeconds: 0.8,
sampleRate: 16_000
)
func testPreMergePlanForShortFinalChunk() {
let chunk = UtteranceAudioChunk(
index: 1,
samples: [Float](repeating: 0.1, count: 4_000),
isLast: true
)
let previous = [Float](repeating: 0.2, count: 80_000)
let plan = FinalChunkRecovery.preMergePlan(
chunk: chunk,
processedChunks: 2,
previousChunkSamples: previous,
config: config
)
XCTAssertNotNil(plan)
XCTAssertGreaterThan(plan?.samples.count ?? 0, chunk.samples.count)
XCTAssertEqual(plan?.stitchIndex, 0)
}
func testEmptyResultRetryPlanUsesOverlapWhenPriorChunkExists() {
let chunk = UtteranceAudioChunk(
index: 1,
samples: [Float](repeating: 0.1, count: 20_000),
isLast: true
)
let previous = [Float](repeating: 0.2, count: 80_000)
let plan = FinalChunkRecovery.emptyResultRetryPlan(
chunk: chunk,
previousChunkSamples: previous,
config: config,
asrText: " "
)
XCTAssertNotNil(plan)
XCTAssertGreaterThan(plan?.samples.count ?? 0, chunk.samples.count)
}
func testEmptyResultRetryPlanRetriesSingleChunkSamples() {
let chunk = UtteranceAudioChunk(
index: 0,
samples: [Float](repeating: 0.1, count: 20_000),
isLast: true
)
let plan = FinalChunkRecovery.emptyResultRetryPlan(
chunk: chunk,
previousChunkSamples: [],
config: config,
asrText: ""
)
XCTAssertEqual(plan?.samples.count, chunk.samples.count)
XCTAssertEqual(plan?.stitchIndex, 0)
}
}
@@ -0,0 +1,35 @@
// FlowUtteranceEndCoordinatorTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class FlowUtteranceEndCoordinatorTests: XCTestCase {
func testAwaitTailCaptureRunsPostRollAfterSilenceDrain() async {
let policy = FlowCaptureTailDrainPolicy(
silenceRMSThreshold: 0.02,
silenceDurationSeconds: 0.05,
maxDrainSeconds: 1.0,
postRollSeconds: 0.08
)
let tracker = FlowCaptureDrainTracker()
let start = Date().timeIntervalSince1970
tracker.beginDrain(now: start)
let timing = await FlowUtteranceEndCoordinator.awaitTailCapture(
tracker: tracker,
policy: policy,
pollIntervalNs: 5_000_000
)
XCTAssertTrue(timing.endedBySilence)
XCTAssertGreaterThanOrEqual(timing.postRollDurationSeconds, 0.07)
}
func testIOSFlowPresetUsesLongerSilenceAndPostRoll() {
XCTAssertEqual(FlowCaptureTailDrainPolicy.iosFlow.silenceDurationSeconds, 0.35)
XCTAssertEqual(FlowCaptureTailDrainPolicy.iosFlow.postRollSeconds, 0.15)
XCTAssertEqual(FlowCaptureTailDrainPolicy.flowDefault, FlowCaptureTailDrainPolicy.iosFlow)
}
}
@@ -0,0 +1,32 @@
// UtteranceTranscriptGuardTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class UtteranceTranscriptGuardTests: XCTestCase {
func testResolvePrefersPartialWhenClearlyLonger() {
let resolved = UtteranceTranscriptGuard.resolve(
stitchedFinal: "今天天气很好",
partialSnapshot: "今天天气很好,我们一起去公园吧"
)
XCTAssertEqual(resolved, "今天天气很好,我们一起去公园吧")
}
func testResolveKeepsFinalWhenPartialIsNotLonger() {
let resolved = UtteranceTranscriptGuard.resolve(
stitchedFinal: "今天天气很好,我们一起去公园吧",
partialSnapshot: "今天天气很好"
)
XCTAssertEqual(resolved, "今天天气很好,我们一起去公园吧")
}
func testResolveUsesPartialWhenFinalEmpty() {
let resolved = UtteranceTranscriptGuard.resolve(
stitchedFinal: "",
partialSnapshot: "最后一段 partial"
)
XCTAssertEqual(resolved, "最后一段 partial")
}
}