feat(flow): add whole-utterance batch ASR fallback (P1)

- Accumulate utterance PCM in FlowContinuousCapture for batch retry
- Run full-utterance transcribeChunk when stitched final lags partial
- Refactor Mac MLX tail drain to shared FlowUtteranceEndCoordinator
- Add FlowUtterancePCMStore, UtteranceBatchFallbackPolicy, and tests

Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-07-26 10:15:31 +00:00
parent c7ee891a90
commit f0240c3088
9 changed files with 256 additions and 3 deletions
@@ -281,6 +281,9 @@ public final class FlowContinuousCapture {
private let gate = OSAllocatedUnfairLock(initialState: UtteranceGatePhase.idle)
private let drainTracker = FlowCaptureDrainTracker()
private let tailSampleCounter = OSAllocatedUnfairLock(initialState: 0)
private let utterancePCMStore = FlowUtterancePCMStore(
maxSampleCount: Int(FlowSessionKeys.maxUtteranceDuration) * 16_000
)
private var downsampler: AdaptiveDownsampler?
private var targetFormat: AVAudioFormat?
@@ -412,6 +415,7 @@ public final class FlowContinuousCapture {
let proof = audioProofStore
let tracker = drainTracker
let tailCounter = tailSampleCounter
let pcmStore = utterancePCMStore
let policy = drainPolicy
let tap = Self.makeAudioTapBlock(
downsampler: downsampler,
@@ -422,6 +426,7 @@ public final class FlowContinuousCapture {
streamRelay: relay,
drainTracker: tracker,
tailSampleCounter: tailCounter,
utterancePCMStore: pcmStore,
drainPolicy: policy
)
// `format: nil` binds the tap to the input node's *live* format. Passing
@@ -645,6 +650,7 @@ public final class FlowContinuousCapture {
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
drainTracker.reset()
tailSampleCounter.withLock { $0 = 0 }
utterancePCMStore.reset()
// Bind the consumer before opening the gate so early tap frames
// are not dropped on the floor.
streamRelay.bind(continuation)
@@ -697,11 +703,17 @@ public final class FlowContinuousCapture {
return report
}
/// Returns the utterance PCM accumulated during the last recording cycle.
public func consumeUtteranceSamples() -> [Float] {
utterancePCMStore.consume()
}
/// Immediate stop without tail drain (abort / session teardown).
public func cancelUtterance() {
gate.withLock { $0 = .idle }
drainTracker.reset()
tailSampleCounter.withLock { $0 = 0 }
utterancePCMStore.reset()
streamRelay.finish()
}
@@ -720,6 +732,7 @@ public final class FlowContinuousCapture {
streamRelay: FlowCaptureStreamRelay,
drainTracker: FlowCaptureDrainTracker,
tailSampleCounter: OSAllocatedUnfairLock<Int>,
utterancePCMStore: FlowUtterancePCMStore,
drainPolicy: FlowCaptureTailDrainPolicy
) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
return { buffer, _ in
@@ -739,6 +752,7 @@ public final class FlowContinuousCapture {
let phase = gate.withLock { $0 }
switch phase {
case .recording, .draining:
utterancePCMStore.append(snapshot.samples)
streamRelay.yield(snapshot)
if phase == .draining {
drainTracker.noteAudio(samples: snapshot.samples, policy: drainPolicy)
@@ -25,6 +25,18 @@ public enum FlowPipelineDiagnostics {
OSGLog.asr.info("finalChunkRecovery \(action) chunk=\(chunkIndex)")
}
public static func logBatchFallback(
sampleCount: Int,
stitchedLength: Int,
partialLength: Int,
batchLength: Int
) {
OSGLog.flow.info(
"batchFallback samples=\(sampleCount) stitchedLen=\(stitchedLength) " +
"partialLen=\(partialLength) batchLen=\(batchLength)"
)
}
public static func logChunkFinalize(
chunkCount: Int,
lastChunkSamples: Int,
@@ -0,0 +1,46 @@
// FlowUtterancePCMStore.swift
// OSGKeyboard · Shared
//
// Thread-safe rolling buffer of 16 kHz mono utterance PCM for whole-utterance
// batch ASR fallback when pipelined chunking drops weak tail segments.
import Foundation
public final class FlowUtterancePCMStore: @unchecked Sendable {
private let lock = OSAllocatedUnfairLock()
private var samples: [Float] = []
private let maxSampleCount: Int
public init(maxSampleCount: Int) {
self.maxSampleCount = max(1, maxSampleCount)
}
public func reset() {
lock.withLock {
samples.removeAll(keepingCapacity: false)
}
}
public func append(_ chunk: [Float]) {
guard !chunk.isEmpty else { return }
lock.withLock {
samples.append(contentsOf: chunk)
if samples.count > maxSampleCount {
samples.removeFirst(samples.count - maxSampleCount)
}
}
}
public var sampleCount: Int {
lock.withLock { samples.count }
}
/// Returns accumulated samples and clears the store.
public func consume() -> [Float] {
lock.withLock {
let out = samples
samples.removeAll(keepingCapacity: false)
return out
}
}
}
@@ -0,0 +1,41 @@
// UtteranceBatchFallbackPolicy.swift
// OSGKeyboard · Shared
//
// Decides when to re-run ASR on the full utterance PCM after pipelined
// chunking, and how to pick the best transcript among candidates.
import Foundation
public enum UtteranceBatchFallbackPolicy {
public static let defaultCharacterAdvantage = UtteranceTranscriptGuard.defaultPartialAdvantage
/// True when chunked output likely lost trailing content vs the live partial.
public static func shouldRunBatchFallback(
stitchedFinal: String,
partialSnapshot: String,
minimumCharacterAdvantage: Int = defaultCharacterAdvantage
) -> Bool {
let final = stitchedFinal.trimmingCharacters(in: .whitespacesAndNewlines)
let partial = partialSnapshot.trimmingCharacters(in: .whitespacesAndNewlines)
if final.isEmpty, !partial.isEmpty { return true }
if partial.isEmpty { return false }
return partial.count >= final.count + minimumCharacterAdvantage
}
/// Prefer the longest non-empty transcript after batch ASR completes.
public static func preferredTranscript(
batch: String,
stitchedFinal: String,
partialSnapshot: String,
current: String
) -> String {
let candidates = [batch, current, stitchedFinal, partialSnapshot]
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard let best = candidates.max(by: { $0.count < $1.count }) else {
return current.trimmingCharacters(in: .whitespacesAndNewlines)
}
return best
}
}