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:
@@ -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 与润色后再插入。
|
||||||
|
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user