From c7ee891a90b40c71f15092cc10d1f52c861da6b1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 10:13:40 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 3 + OSGKeyboard/Services/FlowSessionManager.swift | 16 +++- OSGKeyboardMac/MacMLXLiveCapture.swift | 6 +- .../Services/ChunkedUtterancePipeline.swift | 73 +++++++++++++++--- .../Services/FlowContinuousCapture.swift | 20 ++--- .../Services/LiveDictationController.swift | 10 +-- .../Utilities/FinalChunkRecovery.swift | 47 ++++++++++++ .../Utilities/FlowCaptureTailDrain.swift | 33 +++++++-- .../Utilities/FlowPipelineDiagnostics.swift | 14 +++- .../FlowUtteranceEndCoordinator.swift | 66 +++++++++++++++++ .../Utilities/UtteranceStreamChunker.swift | 6 +- .../Utilities/UtteranceTranscriptGuard.swift | 35 +++++++++ .../ChunkedUtterancePipelineTests.swift | 57 ++++++++++++++ .../FinalChunkRecoveryTests.swift | 74 +++++++++++++++++++ .../FlowUtteranceEndCoordinatorTests.swift | 35 +++++++++ .../UtteranceTranscriptGuardTests.swift | 32 ++++++++ 16 files changed, 485 insertions(+), 42 deletions(-) create mode 100644 OSGKeyboardShared/Utilities/FinalChunkRecovery.swift create mode 100644 OSGKeyboardShared/Utilities/FlowUtteranceEndCoordinator.swift create mode 100644 OSGKeyboardShared/Utilities/UtteranceTranscriptGuard.swift create mode 100644 OSGKeyboardTests/FinalChunkRecoveryTests.swift create mode 100644 OSGKeyboardTests/FlowUtteranceEndCoordinatorTests.swift create mode 100644 OSGKeyboardTests/UtteranceTranscriptGuardTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d831b18..1cdc9e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 - **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 与润色后再插入。 diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index 775321d..91933db 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -64,6 +64,8 @@ final class FlowSessionManager: ObservableObject { private var chunkedPipeline: ChunkedUtterancePipeline? private var currentPartial = "" private var lastFinal = "" + /// Partial stitched text captured when the user stops recording. + private var bestPartialSnapshot = "" private var chunkWarnings: [String] = [] private var lastReadyTraceSignature = "" private var lastCommandFingerprint = "" @@ -356,6 +358,7 @@ final class FlowSessionManager: ObservableObject { sessionWarning = nil currentPartial = "" lastFinal = "" + bestPartialSnapshot = "" chunkWarnings = [] FlowSessionBridge.setHostReady(false) } @@ -1101,6 +1104,7 @@ final class FlowSessionManager: ObservableObject { currentCommandSeq = commandSeq currentPartial = "" lastFinal = "" + bestPartialSnapshot = "" chunkWarnings = [] let localeId = store.localeId @@ -1194,6 +1198,9 @@ final class FlowSessionManager: ObservableObject { refreshHostReady() 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. // Capture ids now: a cancelled finalize must still clear *this* @@ -1231,6 +1238,7 @@ final class FlowSessionManager: ObservableObject { capture.cancelUtterance() currentPartial = "" lastFinal = "" + bestPartialSnapshot = "" chunkWarnings = [] currentUtteranceId = nil currentCommandSeq = 0 @@ -1257,6 +1265,7 @@ final class FlowSessionManager: ObservableObject { capture.cancelUtterance() currentPartial = "" lastFinal = "" + bestPartialSnapshot = "" chunkWarnings = [] storeCurrentError(message, kind: kind) currentUtteranceId = nil @@ -1279,6 +1288,7 @@ final class FlowSessionManager: ObservableObject { chunkedPipeline = nil currentPartial = "" lastFinal = "" + bestPartialSnapshot = "" chunkWarnings = [] storeCurrentError(message, kind: kind) currentUtteranceId = nil @@ -1331,7 +1341,10 @@ final class FlowSessionManager: ObservableObject { let asrElapsed = Date().timeIntervalSince(pipelineStarted) 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 { text = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -1427,6 +1440,7 @@ final class FlowSessionManager: ObservableObject { currentPartial = "" lastFinal = "" + bestPartialSnapshot = "" chunkWarnings = [] chunkedPipeline = nil debug("utterance finalized length=\(text.count)") diff --git a/OSGKeyboardMac/MacMLXLiveCapture.swift b/OSGKeyboardMac/MacMLXLiveCapture.swift index 114853c..46f070f 100644 --- a/OSGKeyboardMac/MacMLXLiveCapture.swift +++ b/OSGKeyboardMac/MacMLXLiveCapture.swift @@ -7,11 +7,7 @@ import Foundation import os enum MacMLXLiveCapture { - private static let tailDrainPolicy = FlowCaptureTailDrainPolicy( - silenceRMSThreshold: 0.015, - silenceDurationSeconds: 0.35, - maxDrainSeconds: 0.75 - ) + private static let tailDrainPolicy = FlowCaptureTailDrainPolicy.macMLX /// Runs MLX streaming ASR until `finishSignal` fires, then tail-drains and finalizes. static func run( diff --git a/OSGKeyboardShared/Services/ChunkedUtterancePipeline.swift b/OSGKeyboardShared/Services/ChunkedUtterancePipeline.swift index 5ea9d39..611a9b9 100644 --- a/OSGKeyboardShared/Services/ChunkedUtterancePipeline.swift +++ b/OSGKeyboardShared/Services/ChunkedUtterancePipeline.swift @@ -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( diff --git a/OSGKeyboardShared/Services/FlowContinuousCapture.swift b/OSGKeyboardShared/Services/FlowContinuousCapture.swift index bfe3a64..7c82350 100644 --- a/OSGKeyboardShared/Services/FlowContinuousCapture.swift +++ b/OSGKeyboardShared/Services/FlowContinuousCapture.swift @@ -666,16 +666,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 +687,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 } diff --git a/OSGKeyboardShared/Services/LiveDictationController.swift b/OSGKeyboardShared/Services/LiveDictationController.swift index fa4d6ac..8f8e5a2 100644 --- a/OSGKeyboardShared/Services/LiveDictationController.swift +++ b/OSGKeyboardShared/Services/LiveDictationController.swift @@ -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 diff --git a/OSGKeyboardShared/Utilities/FinalChunkRecovery.swift b/OSGKeyboardShared/Utilities/FinalChunkRecovery.swift new file mode 100644 index 0000000..df32dbc --- /dev/null +++ b/OSGKeyboardShared/Utilities/FinalChunkRecovery.swift @@ -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)) + } +} diff --git a/OSGKeyboardShared/Utilities/FlowCaptureTailDrain.swift b/OSGKeyboardShared/Utilities/FlowCaptureTailDrain.swift index 937673b..7fead1a 100644 --- a/OSGKeyboardShared/Utilities/FlowCaptureTailDrain.swift +++ b/OSGKeyboardShared/Utilities/FlowCaptureTailDrain.swift @@ -16,22 +16,39 @@ public struct FlowCaptureTailDrainPolicy: Sendable, Equatable { public let silenceDurationSeconds: TimeInterval /// Hard cap so noisy environments cannot stall finalize forever. public let maxDrainSeconds: TimeInterval + /// Fixed post-roll after silence drain; independent of RMS (captures weak tails). + public let postRollSeconds: TimeInterval public init( silenceRMSThreshold: Float, silenceDurationSeconds: TimeInterval, - maxDrainSeconds: TimeInterval + maxDrainSeconds: TimeInterval, + postRollSeconds: TimeInterval = 0 ) { self.silenceRMSThreshold = silenceRMSThreshold self.silenceDurationSeconds = silenceDurationSeconds 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, - silenceDurationSeconds: 0.25, - maxDrainSeconds: 1.5 + silenceDurationSeconds: 0.35, + 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). @@ -39,21 +56,25 @@ public struct FlowCaptureDrainReport: Sendable, Equatable { public let drainDurationSeconds: Double public let endedBySilence: Bool public let tailSampleCount: Int + public let postRollDurationSeconds: Double public init( drainDurationSeconds: Double, endedBySilence: Bool, - tailSampleCount: Int + tailSampleCount: Int, + postRollDurationSeconds: Double = 0 ) { self.drainDurationSeconds = drainDurationSeconds self.endedBySilence = endedBySilence self.tailSampleCount = tailSampleCount + self.postRollDurationSeconds = postRollDurationSeconds } public static let skipped = FlowCaptureDrainReport( drainDurationSeconds: 0, endedBySilence: false, - tailSampleCount: 0 + tailSampleCount: 0, + postRollDurationSeconds: 0 ) } diff --git a/OSGKeyboardShared/Utilities/FlowPipelineDiagnostics.swift b/OSGKeyboardShared/Utilities/FlowPipelineDiagnostics.swift index bfa58f1..c1e3474 100644 --- a/OSGKeyboardShared/Utilities/FlowPipelineDiagnostics.swift +++ b/OSGKeyboardShared/Utilities/FlowPipelineDiagnostics.swift @@ -9,10 +9,22 @@ import os public enum FlowPipelineDiagnostics { public static func logDrain(_ report: FlowCaptureDrainReport) { 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( chunkCount: Int, lastChunkSamples: Int, diff --git a/OSGKeyboardShared/Utilities/FlowUtteranceEndCoordinator.swift b/OSGKeyboardShared/Utilities/FlowUtteranceEndCoordinator.swift new file mode 100644 index 0000000..0714c19 --- /dev/null +++ b/OSGKeyboardShared/Utilities/FlowUtteranceEndCoordinator.swift @@ -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)) + } +} diff --git a/OSGKeyboardShared/Utilities/UtteranceStreamChunker.swift b/OSGKeyboardShared/Utilities/UtteranceStreamChunker.swift index 5a052b5..7d620ed 100644 --- a/OSGKeyboardShared/Utilities/UtteranceStreamChunker.swift +++ b/OSGKeyboardShared/Utilities/UtteranceStreamChunker.swift @@ -56,7 +56,11 @@ public enum UtteranceStreamChunker { } else if chunkIndex == 0 { // Empty utterance — no chunks. } 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() diff --git a/OSGKeyboardShared/Utilities/UtteranceTranscriptGuard.swift b/OSGKeyboardShared/Utilities/UtteranceTranscriptGuard.swift new file mode 100644 index 0000000..1409939 --- /dev/null +++ b/OSGKeyboardShared/Utilities/UtteranceTranscriptGuard.swift @@ -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 + } +} diff --git a/OSGKeyboardTests/ChunkedUtterancePipelineTests.swift b/OSGKeyboardTests/ChunkedUtterancePipelineTests.swift index 22263b1..7a47808 100644 --- a/OSGKeyboardTests/ChunkedUtterancePipelineTests.swift +++ b/OSGKeyboardTests/ChunkedUtterancePipelineTests.swift @@ -115,6 +115,34 @@ final class ChunkedUtterancePipelineTests: XCTestCase { } 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.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 { @@ -171,3 +199,32 @@ private struct ShortFinalMergeStubASR: ASRService, @unchecked Sendable { return .success("short") } } + +private struct EmptyFinalRetryStubASR: ASRService, @unchecked Sendable { + private let callIndex = OSAllocatedUnfairLock(initialState: 0) + + func transcribe( + stream: AsyncStream, + locale: Locale + ) -> AsyncStream { + 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") + } +} diff --git a/OSGKeyboardTests/FinalChunkRecoveryTests.swift b/OSGKeyboardTests/FinalChunkRecoveryTests.swift new file mode 100644 index 0000000..be45ac8 --- /dev/null +++ b/OSGKeyboardTests/FinalChunkRecoveryTests.swift @@ -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) + } +} diff --git a/OSGKeyboardTests/FlowUtteranceEndCoordinatorTests.swift b/OSGKeyboardTests/FlowUtteranceEndCoordinatorTests.swift new file mode 100644 index 0000000..dc1373f --- /dev/null +++ b/OSGKeyboardTests/FlowUtteranceEndCoordinatorTests.swift @@ -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) + } +} diff --git a/OSGKeyboardTests/UtteranceTranscriptGuardTests.swift b/OSGKeyboardTests/UtteranceTranscriptGuardTests.swift new file mode 100644 index 0000000..62cc35f --- /dev/null +++ b/OSGKeyboardTests/UtteranceTranscriptGuardTests.swift @@ -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") + } +}