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
@@ -0,0 +1,66 @@
// FlowUtteranceEndCoordinator.swift
// OSGKeyboard · Shared
//
// Unified tail-drain + post-roll orchestration after the user stops recording.
// Keeps the mic gate open through silence detection, then a fixed post-roll
// window that does not depend on RMS (captures weak trailing syllables).
import Foundation
/// Outcome of `FlowUtteranceEndCoordinator.awaitTailCapture`.
public struct FlowUtteranceEndTiming: Sendable, Equatable {
public let endedBySilence: Bool
public let postRollDurationSeconds: Double
public init(endedBySilence: Bool, postRollDurationSeconds: Double) {
self.endedBySilence = endedBySilence
self.postRollDurationSeconds = postRollDurationSeconds
}
}
public enum FlowUtteranceEndCoordinator {
/// Poll interval while waiting for silence / max drain (20 ms).
public static let pollIntervalNs: UInt64 = 20_000_000
/// Waits until trailing speech drains (silence or max cap), then sleeps
/// through a fixed post-roll window so weak tail audio still reaches ASR.
///
/// Callers must keep forwarding PCM from the audio tap while this runs
/// (gate `.draining` or equivalent).
public static func awaitTailCapture(
tracker: FlowCaptureDrainTracker,
policy: FlowCaptureTailDrainPolicy,
pollIntervalNs: UInt64 = pollIntervalNs
) async -> FlowUtteranceEndTiming {
var endedBySilence = false
while true {
let decision = tracker.shouldFinish(policy: policy)
if decision.finished {
endedBySilence = decision.endedBySilence
break
}
if Task.isCancelled { break }
try? await Task.sleep(nanoseconds: pollIntervalNs)
}
let postRoll = await runPostRoll(policy: policy, pollIntervalNs: pollIntervalNs)
return FlowUtteranceEndTiming(
endedBySilence: endedBySilence,
postRollDurationSeconds: postRoll
)
}
private static func runPostRoll(
policy: FlowCaptureTailDrainPolicy,
pollIntervalNs: UInt64
) async -> Double {
guard policy.postRollSeconds > 0 else { return 0 }
let started = Date()
let deadline = started.addingTimeInterval(policy.postRollSeconds)
while Date() < deadline {
if Task.isCancelled { break }
try? await Task.sleep(nanoseconds: pollIntervalNs)
}
return max(0, Date().timeIntervalSince(started))
}
}