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
+3
View File
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed
- **Flow tail ASR drop**: after mic stop, iOS Flow now uses a longer silence drain (350 ms), a fixed 150 ms post-roll, expanded final-chunk ASR recovery, and a partial transcript guard so weak trailing syllables are less likely to disappear from the result. / **Flow 尾音识别丢失**:松手后 iOS Flow 采用更长的静音排空(350 ms)、固定 150 ms 尾音保留、增强末块 ASR 恢复与 partial 兜底,降低弱尾音从结果中消失的概率。
### Added ### Added
- **Mac MLX streaming ASR**: local dictation uses Qwen3-ASR via mlx-audio-swift with overlay partial preview, tail drain, vocabulary prompt, and polish-before-insert. / **Mac MLX 流式 ASR**:本地听写改用 mlx-audio-swift 的 Qwen3-ASR,支持浮层 partial 预览、尾部截断、词库 prompt 与润色后再插入。 - **Mac MLX streaming ASR**: local dictation uses Qwen3-ASR via mlx-audio-swift with overlay partial preview, tail drain, vocabulary prompt, and polish-before-insert. / **Mac MLX 流式 ASR**:本地听写改用 mlx-audio-swift 的 Qwen3-ASR,支持浮层 partial 预览、尾部截断、词库 prompt 与润色后再插入。
+15 -1
View File
@@ -64,6 +64,8 @@ final class FlowSessionManager: ObservableObject {
private var chunkedPipeline: ChunkedUtterancePipeline? private var chunkedPipeline: ChunkedUtterancePipeline?
private var currentPartial = "" private var currentPartial = ""
private var lastFinal = "" private var lastFinal = ""
/// Partial stitched text captured when the user stops recording.
private var bestPartialSnapshot = ""
private var chunkWarnings: [String] = [] private var chunkWarnings: [String] = []
private var lastReadyTraceSignature = "" private var lastReadyTraceSignature = ""
private var lastCommandFingerprint = "" private var lastCommandFingerprint = ""
@@ -356,6 +358,7 @@ final class FlowSessionManager: ObservableObject {
sessionWarning = nil sessionWarning = nil
currentPartial = "" currentPartial = ""
lastFinal = "" lastFinal = ""
bestPartialSnapshot = ""
chunkWarnings = [] chunkWarnings = []
FlowSessionBridge.setHostReady(false) FlowSessionBridge.setHostReady(false)
} }
@@ -1101,6 +1104,7 @@ final class FlowSessionManager: ObservableObject {
currentCommandSeq = commandSeq currentCommandSeq = commandSeq
currentPartial = "" currentPartial = ""
lastFinal = "" lastFinal = ""
bestPartialSnapshot = ""
chunkWarnings = [] chunkWarnings = []
let localeId = store.localeId let localeId = store.localeId
@@ -1194,6 +1198,9 @@ final class FlowSessionManager: ObservableObject {
refreshHostReady() refreshHostReady()
FlowLiveActivityController.update(phase: .processing) FlowLiveActivityController.update(phase: .processing)
// Snapshot pipelined partial before drain fallback if the final chunk ASR drops tail text.
bestPartialSnapshot = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
// Do NOT cancel `asrTask` or `asr` drain trailing PCM, then finalize. // Do NOT cancel `asrTask` or `asr` drain trailing PCM, then finalize.
// Capture ids now: a cancelled finalize must still clear *this* // Capture ids now: a cancelled finalize must still clear *this*
@@ -1231,6 +1238,7 @@ final class FlowSessionManager: ObservableObject {
capture.cancelUtterance() capture.cancelUtterance()
currentPartial = "" currentPartial = ""
lastFinal = "" lastFinal = ""
bestPartialSnapshot = ""
chunkWarnings = [] chunkWarnings = []
currentUtteranceId = nil currentUtteranceId = nil
currentCommandSeq = 0 currentCommandSeq = 0
@@ -1257,6 +1265,7 @@ final class FlowSessionManager: ObservableObject {
capture.cancelUtterance() capture.cancelUtterance()
currentPartial = "" currentPartial = ""
lastFinal = "" lastFinal = ""
bestPartialSnapshot = ""
chunkWarnings = [] chunkWarnings = []
storeCurrentError(message, kind: kind) storeCurrentError(message, kind: kind)
currentUtteranceId = nil currentUtteranceId = nil
@@ -1279,6 +1288,7 @@ final class FlowSessionManager: ObservableObject {
chunkedPipeline = nil chunkedPipeline = nil
currentPartial = "" currentPartial = ""
lastFinal = "" lastFinal = ""
bestPartialSnapshot = ""
chunkWarnings = [] chunkWarnings = []
storeCurrentError(message, kind: kind) storeCurrentError(message, kind: kind)
currentUtteranceId = nil currentUtteranceId = nil
@@ -1331,7 +1341,10 @@ final class FlowSessionManager: ObservableObject {
let asrElapsed = Date().timeIntervalSince(pipelineStarted) let asrElapsed = Date().timeIntervalSince(pipelineStarted)
FlowDiagnostics.log("ASR phase done in \(String(format: "%.1f", asrElapsed))s finalLen=\(lastFinal.count)") FlowDiagnostics.log("ASR phase done in \(String(format: "%.1f", asrElapsed))s finalLen=\(lastFinal.count)")
var text = lastFinal.trimmingCharacters(in: .whitespacesAndNewlines) var text = UtteranceTranscriptGuard.resolve(
stitchedFinal: lastFinal,
partialSnapshot: bestPartialSnapshot
)
if text.isEmpty { if text.isEmpty {
text = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines) text = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
} }
@@ -1427,6 +1440,7 @@ final class FlowSessionManager: ObservableObject {
currentPartial = "" currentPartial = ""
lastFinal = "" lastFinal = ""
bestPartialSnapshot = ""
chunkWarnings = [] chunkWarnings = []
chunkedPipeline = nil chunkedPipeline = nil
debug("utterance finalized length=\(text.count)") debug("utterance finalized length=\(text.count)")
+1 -5
View File
@@ -7,11 +7,7 @@ import Foundation
import os import os
enum MacMLXLiveCapture { enum MacMLXLiveCapture {
private static let tailDrainPolicy = FlowCaptureTailDrainPolicy( private static let tailDrainPolicy = FlowCaptureTailDrainPolicy.macMLX
silenceRMSThreshold: 0.015,
silenceDurationSeconds: 0.35,
maxDrainSeconds: 0.75
)
/// Runs MLX streaming ASR until `finishSignal` fires, then tail-drains and finalizes. /// Runs MLX streaming ASR until `finishSignal` fires, then tail-drains and finalizes.
static func run( static func run(
@@ -101,6 +101,7 @@ public actor ChunkedUtterancePipeline {
var processedChunks = 0 var processedChunks = 0
var previousChunkSamples: [Float] = [] var previousChunkSamples: [Float] = []
var lastChunkSamples = 0 var lastChunkSamples = 0
var didRetryEmptyFinal = false
let feeder = Task { let feeder = Task {
for await chunk in UtteranceStreamChunker.chunks(from: stream, config: config) { 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 } guard let chunk = await queue.dequeue() else { break }
if chunk.isLast && chunk.samples.isEmpty {
continue
}
processedChunks += 1 processedChunks += 1
lastChunkSamples = chunk.samples.count lastChunkSamples = chunk.samples.count
if chunk.isLast, if let preMerge = FinalChunkRecovery.preMergePlan(
chunk.samples.count < config.minFinalChunkSamples, chunk: chunk,
processedChunks > 1, processedChunks: processedChunks,
!previousChunkSamples.isEmpty { previousChunkSamples: previousChunkSamples,
let mergedSamples = Array(previousChunkSamples.suffix(config.overlapSamples)) config: config
+ chunk.samples ) {
let mergedResult = await transcribeChunk(samples: mergedSamples) FlowPipelineDiagnostics.logFinalChunkRecovery(
action: "preMerge",
chunkIndex: chunk.index
)
let mergedResult = await transcribeChunk(samples: preMerge.samples)
switch mergedResult { switch mergedResult {
case .success(let text): case .success(let text):
stitcher.removeLastSegment() stitcher.removeLastSegment()
stitcher.append(index: max(0, chunk.index - 1), text: text) stitcher.append(index: preMerge.stitchIndex, text: text)
publishPartial(from: stitcher, onPartial: onPartial) publishPartial(from: stitcher, onPartial: onPartial)
case .failure(let message): case .failure(let message):
failedChunks += 1 failedChunks += 1
@@ -153,8 +162,52 @@ public actor ChunkedUtterancePipeline {
let result = await transcribeChunk(samples: chunk.samples) let result = await transcribeChunk(samples: chunk.samples)
switch result { switch result {
case .success(let text): case .success(let text):
stitcher.append(index: chunk.index, text: text) if chunk.isLast,
publishPartial(from: stitcher, onPartial: onPartial) !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): case .failure(let message):
failedChunks += 1 failedChunks += 1
chunkWarnings.append( chunkWarnings.append(
@@ -666,16 +666,11 @@ public final class FlowContinuousCapture {
gate.withLock { $0 = .draining } gate.withLock { $0 = .draining }
drainTracker.beginDrain() drainTracker.beginDrain()
var endedBySilence = false let timing = await FlowUtteranceEndCoordinator.awaitTailCapture(
while true { tracker: drainTracker,
let decision = drainTracker.shouldFinish(policy: policy) policy: policy,
if decision.finished { pollIntervalNs: FlowCaptureConstants.drainPollIntervalNs
endedBySilence = decision.endedBySilence )
break
}
if Task.isCancelled { break }
try? await Task.sleep(nanoseconds: FlowCaptureConstants.drainPollIntervalNs)
}
// NOTE: We intentionally do NOT signal `.endOfStream` to the shared // NOTE: We intentionally do NOT signal `.endOfStream` to the shared
// downsampling converter here. `AVAudioConverter` is stateful: once its // downsampling converter here. `AVAudioConverter` is stateful: once its
@@ -692,8 +687,9 @@ public final class FlowContinuousCapture {
let tailSamples = tailSampleCounter.withLock { $0 } let tailSamples = tailSampleCounter.withLock { $0 }
let report = FlowCaptureDrainReport( let report = FlowCaptureDrainReport(
drainDurationSeconds: drainTracker.elapsedSeconds(), drainDurationSeconds: drainTracker.elapsedSeconds(),
endedBySilence: endedBySilence, endedBySilence: timing.endedBySilence,
tailSampleCount: tailSamples tailSampleCount: tailSamples,
postRollDurationSeconds: timing.postRollDurationSeconds
) )
drainTracker.reset() drainTracker.reset()
tailSampleCounter.withLock { $0 = 0 } tailSampleCounter.withLock { $0 = 0 }
@@ -558,12 +558,10 @@ public final class LiveDictationController: ObservableObject {
drainTracker.beginDrain() drainTracker.beginDrain()
let policy = FlowCaptureTailDrainPolicy.flowDefault let policy = FlowCaptureTailDrainPolicy.flowDefault
while true { _ = await FlowUtteranceEndCoordinator.awaitTailCapture(
let decision = drainTracker.shouldFinish(policy: policy) tracker: drainTracker,
if decision.finished { break } policy: policy
if Task.isCancelled { break } )
try? await Task.sleep(nanoseconds: 20_000_000)
}
// Trailing speech is preserved by the live `.draining` forwarding // Trailing speech is preserved by the live `.draining` forwarding
// loop above. We deliberately do NOT signal `.endOfStream` to the // loop above. We deliberately do NOT signal `.endOfStream` to the
@@ -0,0 +1,47 @@
// FinalChunkRecovery.swift
// OSGKeyboard · Shared
//
// Recovery plans for pipelined utterance ASR when the final chunk is short,
// empty, or straddles a chunk boundary.
import Foundation
public enum FinalChunkRecovery {
/// Samples and stitch index when the final chunk should be merged with
/// prior overlap *before* the first ASR pass.
public static func preMergePlan(
chunk: UtteranceAudioChunk,
processedChunks: Int,
previousChunkSamples: [Float],
config: FlowUtteranceChunkConfig
) -> (samples: [Float], stitchIndex: Int)? {
guard chunk.isLast, !chunk.samples.isEmpty, !previousChunkSamples.isEmpty else {
return nil
}
guard chunk.samples.count < config.minFinalChunkSamples else { return nil }
let merged = Array(previousChunkSamples.suffix(config.overlapSamples)) + chunk.samples
return (merged, max(0, chunk.index - 1))
}
/// Retry plan when the final chunk had audio but ASR returned empty text.
public static func emptyResultRetryPlan(
chunk: UtteranceAudioChunk,
previousChunkSamples: [Float],
config: FlowUtteranceChunkConfig,
asrText: String
) -> (samples: [Float], stitchIndex: Int)? {
guard chunk.isLast, !chunk.samples.isEmpty else { return nil }
guard asrText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
if previousChunkSamples.isEmpty {
return (chunk.samples, chunk.index)
}
let merged = Array(previousChunkSamples.suffix(config.overlapSamples)) + chunk.samples
return (merged, max(0, chunk.index - 1))
}
}
@@ -16,22 +16,39 @@ public struct FlowCaptureTailDrainPolicy: Sendable, Equatable {
public let silenceDurationSeconds: TimeInterval public let silenceDurationSeconds: TimeInterval
/// Hard cap so noisy environments cannot stall finalize forever. /// Hard cap so noisy environments cannot stall finalize forever.
public let maxDrainSeconds: TimeInterval public let maxDrainSeconds: TimeInterval
/// Fixed post-roll after silence drain; independent of RMS (captures weak tails).
public let postRollSeconds: TimeInterval
public init( public init(
silenceRMSThreshold: Float, silenceRMSThreshold: Float,
silenceDurationSeconds: TimeInterval, silenceDurationSeconds: TimeInterval,
maxDrainSeconds: TimeInterval maxDrainSeconds: TimeInterval,
postRollSeconds: TimeInterval = 0
) { ) {
self.silenceRMSThreshold = silenceRMSThreshold self.silenceRMSThreshold = silenceRMSThreshold
self.silenceDurationSeconds = silenceDurationSeconds self.silenceDurationSeconds = silenceDurationSeconds
self.maxDrainSeconds = maxDrainSeconds self.maxDrainSeconds = maxDrainSeconds
self.postRollSeconds = postRollSeconds
} }
public static let flowDefault = FlowCaptureTailDrainPolicy( /// iOS Flow host + keyboard utterance capture.
public static let iosFlow = FlowCaptureTailDrainPolicy(
silenceRMSThreshold: 0.015, silenceRMSThreshold: 0.015,
silenceDurationSeconds: 0.25, silenceDurationSeconds: 0.35,
maxDrainSeconds: 1.5 maxDrainSeconds: 1.5,
postRollSeconds: 0.15
) )
/// Mac MLX streaming live capture.
public static let macMLX = FlowCaptureTailDrainPolicy(
silenceRMSThreshold: 0.015,
silenceDurationSeconds: 0.35,
maxDrainSeconds: 0.75,
postRollSeconds: 0.15
)
/// Backward-compatible alias for iOS Flow defaults.
public static let flowDefault = iosFlow
} }
/// Metrics emitted when tail drain completes (for diagnostics and tests). /// Metrics emitted when tail drain completes (for diagnostics and tests).
@@ -39,21 +56,25 @@ public struct FlowCaptureDrainReport: Sendable, Equatable {
public let drainDurationSeconds: Double public let drainDurationSeconds: Double
public let endedBySilence: Bool public let endedBySilence: Bool
public let tailSampleCount: Int public let tailSampleCount: Int
public let postRollDurationSeconds: Double
public init( public init(
drainDurationSeconds: Double, drainDurationSeconds: Double,
endedBySilence: Bool, endedBySilence: Bool,
tailSampleCount: Int tailSampleCount: Int,
postRollDurationSeconds: Double = 0
) { ) {
self.drainDurationSeconds = drainDurationSeconds self.drainDurationSeconds = drainDurationSeconds
self.endedBySilence = endedBySilence self.endedBySilence = endedBySilence
self.tailSampleCount = tailSampleCount self.tailSampleCount = tailSampleCount
self.postRollDurationSeconds = postRollDurationSeconds
} }
public static let skipped = FlowCaptureDrainReport( public static let skipped = FlowCaptureDrainReport(
drainDurationSeconds: 0, drainDurationSeconds: 0,
endedBySilence: false, endedBySilence: false,
tailSampleCount: 0 tailSampleCount: 0,
postRollDurationSeconds: 0
) )
} }
@@ -9,10 +9,22 @@ import os
public enum FlowPipelineDiagnostics { public enum FlowPipelineDiagnostics {
public static func logDrain(_ report: FlowCaptureDrainReport) { public static func logDrain(_ report: FlowCaptureDrainReport) {
OSGLog.flow.info( OSGLog.flow.info(
"tailDrain duration=\(report.drainDurationSeconds, format: .fixed(precision: 2))s silenceEnd=\(report.endedBySilence) tailSamples=\(report.tailSampleCount)" "tailDrain duration=\(report.drainDurationSeconds, format: .fixed(precision: 2))s " +
"postRoll=\(report.postRollDurationSeconds, format: .fixed(precision: 2))s " +
"silenceEnd=\(report.endedBySilence) tailSamples=\(report.tailSampleCount)"
) )
} }
public static func logTranscriptGuardUsedPartial(finalLength: Int, partialLength: Int) {
OSGLog.flow.warning(
"transcriptGuard partial preferred finalLen=\(finalLength) partialLen=\(partialLength)"
)
}
public static func logFinalChunkRecovery(action: String, chunkIndex: Int) {
OSGLog.asr.info("finalChunkRecovery \(action) chunk=\(chunkIndex)")
}
public static func logChunkFinalize( public static func logChunkFinalize(
chunkCount: Int, chunkCount: Int,
lastChunkSamples: Int, lastChunkSamples: Int,
@@ -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))
}
}
@@ -56,7 +56,11 @@ public enum UtteranceStreamChunker {
} else if chunkIndex == 0 { } else if chunkIndex == 0 {
// Empty utterance no chunks. // Empty utterance no chunks.
} else { } else {
// Stream ended exactly on boundary; mark prior path complete. // Stream ended exactly on a chunk boundary; prior emit holds
// all tail audio. Marker so FinalChunkRecovery paths run.
continuation.yield(
UtteranceAudioChunk(index: chunkIndex, samples: [], isLast: true)
)
} }
continuation.finish() continuation.finish()
@@ -0,0 +1,35 @@
// UtteranceTranscriptGuard.swift
// OSGKeyboard · Shared
//
// Chooses the best available transcript when pipelined final text may have
// dropped a weak tail segment.
import Foundation
public enum UtteranceTranscriptGuard {
/// Partial must exceed final by at least this many characters to win.
public static let defaultPartialAdvantage = 8
/// Prefer `stitchedFinal` unless empty or clearly shorter than the live
/// partial snapshot taken at mic stop.
public static func resolve(
stitchedFinal: String,
partialSnapshot: String,
minimumPartialAdvantage: Int = defaultPartialAdvantage
) -> String {
let final = stitchedFinal.trimmingCharacters(in: .whitespacesAndNewlines)
let partial = partialSnapshot.trimmingCharacters(in: .whitespacesAndNewlines)
if final.isEmpty { return partial }
if partial.isEmpty { return final }
if partial.count >= final.count + minimumPartialAdvantage {
FlowPipelineDiagnostics.logTranscriptGuardUsedPartial(
finalLength: final.count,
partialLength: partial.count
)
return partial
}
return final
}
}
@@ -115,6 +115,34 @@ final class ChunkedUtterancePipelineTests: XCTestCase {
} }
XCTAssertTrue(success.text.contains("merged")) 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 { private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
@@ -171,3 +199,32 @@ private struct ShortFinalMergeStubASR: ASRService, @unchecked Sendable {
return .success("short") 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")
}
}