fix(asr): keep recordings when streaming recognition fails or stalls

- Keep recording after a streaming ASR error and retry the captured PCM
  through batch ASR before reporting a failure.
- Treat an expired ASR wait as a stall so batch fallback still runs.
- Prime the managed Volcengine stream on mic touch and adopt it only while
  fresh and open.
- Auto-scroll the live transcript to the newest recognized text.
This commit is contained in:
2026-10-20 20:05:08 +02:00
parent f7a031cc1d
commit be56d2210c
9 changed files with 518 additions and 20 deletions
+25 -4
View File
@@ -2109,6 +2109,13 @@ final class FlowSessionManager: ObservableObject {
!hasUnacknowledgedTerminalResult() else {
return
}
bindSessionASRIfNeeded()
if let cloudASR = sessionASR as? CloudASRService {
// Same locale source as `beginUtterance`, so the prime is adoptable.
cloudASR.primeUtteranceStreaming(
locale: SpeechLocaleResolver.resolve(store.localeId)
)
}
startAudioPrime(id: command.utteranceId, origin: "micTouch")
}
@@ -2171,6 +2178,7 @@ final class FlowSessionManager: ObservableObject {
!isUtteranceProcessing else {
return
}
(sessionASR as? CloudASRService)?.cancelPrimedUtteranceStreaming()
audioPrimeCancellationRequested = true
// `capture.start()` is not cancellation-safe. Never stop it mid-start;
// the completion waiter performs cleanup unless a real utterance adopts
@@ -2613,11 +2621,14 @@ final class FlowSessionManager: ObservableObject {
]
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.first(where: { !$0.isEmpty })
// Without a partial, keep recording: capture is independent
// of ASR, and finalize retries the full PCM through batch
// ASR (`recognitionFailed`) before reporting the error.
if let recovery {
manager.lastFinal = recovery
manager.debug("asr error recovered via partial len=\(recovery.count)")
} else if manager.isUtteranceRecording {
manager.failUtterance(message: message, kind: .asrFailed)
manager.debug("asr error while recording — deferring to batch fallback")
}
case .cancelled:
// A provider-side cancellation while the mic is still open
@@ -2935,8 +2946,16 @@ final class FlowSessionManager: ObservableObject {
try? await Task.sleep(nanoseconds: 100_000_000)
}
// The recognizer never reported back within the wait: treat it as a
// failure so batch ASR can still recover the captured audio.
let asrStalled = lastFinal.isEmpty
&& asrCompletedGeneration != finalizeGeneration
&& !Task.isCancelled
if lastFinal.isEmpty {
FlowDiagnostics.log("ASR wait elapsed — cancelling ASR task and using best available transcript")
FlowDiagnostics.log(
"ASR wait elapsed — cancelling ASR task and using best available transcript "
+ "stalled=\(asrStalled ? 1 : 0)"
)
asrTask?.cancel()
Task { await chunkedPipeline?.cancel() }
}
@@ -2973,11 +2992,13 @@ final class FlowSessionManager: ObservableObject {
let wantsBatchFallback = UtteranceBatchFallbackPolicy.shouldRunBatchFallback(
stitchedFinal: lastFinal,
partialSnapshot: bestPartialSnapshot,
recognitionFailed: asrFailureMessage != nil
recognitionFailed: asrFailureMessage != nil,
recognitionStalled: asrStalled
)
FlowTrace.pipeline(
"batchFallback.decision",
"wanted=\(wantsBatchFallback ? 1 : 0) pcmSamples=\(utterancePCMSamples.count) "
"wanted=\(wantsBatchFallback ? 1 : 0) stalled=\(asrStalled ? 1 : 0) "
+ "pcmSamples=\(utterancePCMSamples.count) "
+ "pcmRms=\(FlowTrace.rms(utterancePCMSamples)) "
+ "stitchedLen=\(lastFinal.count) partialLen=\(bestPartialSnapshot.count) "
+ "resolvedLen=\(text.count)"