merge: tail audio ASR fix (P0+P1) into feature/polish-style-packs

Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-07-26 10:34:05 +00:00
20 changed files with 741 additions and 45 deletions
@@ -101,6 +101,7 @@ public actor ChunkedUtterancePipeline {
var processedChunks = 0
var previousChunkSamples: [Float] = []
var lastChunkSamples = 0
var didRetryEmptyFinal = false
let feeder = Task {
for await chunk in UtteranceStreamChunker.chunks(from: stream, config: config) {
@@ -118,20 +119,28 @@ public actor ChunkedUtterancePipeline {
guard let chunk = await queue.dequeue() else { break }
if chunk.isLast && chunk.samples.isEmpty {
continue
}
processedChunks += 1
lastChunkSamples = chunk.samples.count
if chunk.isLast,
chunk.samples.count < config.minFinalChunkSamples,
processedChunks > 1,
!previousChunkSamples.isEmpty {
let mergedSamples = Array(previousChunkSamples.suffix(config.overlapSamples))
+ chunk.samples
let mergedResult = await transcribeChunk(samples: mergedSamples)
if let preMerge = FinalChunkRecovery.preMergePlan(
chunk: chunk,
processedChunks: processedChunks,
previousChunkSamples: previousChunkSamples,
config: config
) {
FlowPipelineDiagnostics.logFinalChunkRecovery(
action: "preMerge",
chunkIndex: chunk.index
)
let mergedResult = await transcribeChunk(samples: preMerge.samples)
switch mergedResult {
case .success(let text):
stitcher.removeLastSegment()
stitcher.append(index: max(0, chunk.index - 1), text: text)
stitcher.append(index: preMerge.stitchIndex, text: text)
publishPartial(from: stitcher, onPartial: onPartial)
case .failure(let message):
failedChunks += 1
@@ -153,8 +162,52 @@ public actor ChunkedUtterancePipeline {
let result = await transcribeChunk(samples: chunk.samples)
switch result {
case .success(let text):
stitcher.append(index: chunk.index, text: text)
publishPartial(from: stitcher, onPartial: onPartial)
if chunk.isLast,
!didRetryEmptyFinal,
let retry = FinalChunkRecovery.emptyResultRetryPlan(
chunk: chunk,
previousChunkSamples: previousChunkSamples,
config: config,
asrText: text
) {
didRetryEmptyFinal = true
FlowPipelineDiagnostics.logFinalChunkRecovery(
action: "emptyRetry",
chunkIndex: chunk.index
)
let retryResult = await transcribeChunk(samples: retry.samples)
switch retryResult {
case .success(let retryText):
let trimmed = retryText.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
if retry.stitchIndex < chunk.index {
stitcher.removeLastSegment()
}
stitcher.append(index: retry.stitchIndex, text: retryText)
publishPartial(from: stitcher, onPartial: onPartial)
} else {
stitcher.append(index: chunk.index, text: text)
publishPartial(from: stitcher, onPartial: onPartial)
}
case .failure(let message):
stitcher.append(index: chunk.index, text: text)
publishPartial(from: stitcher, onPartial: onPartial)
failedChunks += 1
chunkWarnings.append(
SharedL10n.format(
"error.asr.chunkFailed",
chunk.index + 1,
message
)
)
case .cancelled:
feeder.cancel()
return .cancelled
}
} else {
stitcher.append(index: chunk.index, text: text)
publishPartial(from: stitcher, onPartial: onPartial)
}
case .failure(let message):
failedChunks += 1
chunkWarnings.append(
@@ -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)
@@ -666,16 +672,11 @@ public final class FlowContinuousCapture {
gate.withLock { $0 = .draining }
drainTracker.beginDrain()
var endedBySilence = false
while true {
let decision = drainTracker.shouldFinish(policy: policy)
if decision.finished {
endedBySilence = decision.endedBySilence
break
}
if Task.isCancelled { break }
try? await Task.sleep(nanoseconds: FlowCaptureConstants.drainPollIntervalNs)
}
let timing = await FlowUtteranceEndCoordinator.awaitTailCapture(
tracker: drainTracker,
policy: policy,
pollIntervalNs: FlowCaptureConstants.drainPollIntervalNs
)
// NOTE: We intentionally do NOT signal `.endOfStream` to the shared
// downsampling converter here. `AVAudioConverter` is stateful: once its
@@ -692,8 +693,9 @@ public final class FlowContinuousCapture {
let tailSamples = tailSampleCounter.withLock { $0 }
let report = FlowCaptureDrainReport(
drainDurationSeconds: drainTracker.elapsedSeconds(),
endedBySilence: endedBySilence,
tailSampleCount: tailSamples
endedBySilence: timing.endedBySilence,
tailSampleCount: tailSamples,
postRollDurationSeconds: timing.postRollDurationSeconds
)
drainTracker.reset()
tailSampleCounter.withLock { $0 = 0 }
@@ -701,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()
}
@@ -724,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
@@ -743,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)
@@ -558,12 +558,10 @@ public final class LiveDictationController: ObservableObject {
drainTracker.beginDrain()
let policy = FlowCaptureTailDrainPolicy.flowDefault
while true {
let decision = drainTracker.shouldFinish(policy: policy)
if decision.finished { break }
if Task.isCancelled { break }
try? await Task.sleep(nanoseconds: 20_000_000)
}
_ = await FlowUtteranceEndCoordinator.awaitTailCapture(
tracker: drainTracker,
policy: policy
)
// Trailing speech is preserved by the live `.draining` forwarding
// loop above. We deliberately do NOT signal `.endOfStream` to the