fix(flow): drain trailing audio and harden chunked ASR pipeline
- P0/P1: tail-drain state machine in FlowContinuousCapture with converter flush; FlowSessionManager awaits drain before finalize - P2: FlowCaptureTailDrain policy/tracker, pipeline diagnostics, unit tests - P3: short final chunk merged re-transcription in ChunkedUtterancePipeline - P4: UtteranceTranscriptStitcher composedSafely fallback; preview path parity in LiveDictationController Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
@@ -87,6 +87,34 @@ final class ChunkedUtterancePipelineTests: XCTestCase {
|
||||
XCTAssertFalse(success.text.isEmpty)
|
||||
XCTAssertEqual(success.chunkWarnings.count, 1)
|
||||
}
|
||||
|
||||
func testPipelineRetranscribesShortFinalChunkWithPriorOverlap() async {
|
||||
let config = FlowUtteranceChunkConfig(
|
||||
maxChunkDurationSeconds: 0.05,
|
||||
overlapDurationSeconds: 10,
|
||||
pauseExtensionMaxSeconds: 0,
|
||||
pauseRMSThreshold: 0.02,
|
||||
minFinalChunkDurationSeconds: 0.05,
|
||||
sampleRate: 1_000
|
||||
)
|
||||
let asr = ShortFinalMergeStubASR()
|
||||
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: 20), 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("merged"))
|
||||
}
|
||||
}
|
||||
|
||||
private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
|
||||
@@ -114,3 +142,32 @@ private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
|
||||
return .success("seg\(samples.count)")
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShortFinalMergeStubASR: 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 samples.count > 20 {
|
||||
return .success("merged-tail")
|
||||
}
|
||||
return .success("short")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// FlowCaptureTailDrainTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class FlowCaptureTailDrainTests: XCTestCase {
|
||||
|
||||
private let policy = FlowCaptureTailDrainPolicy(
|
||||
silenceRMSThreshold: 0.02,
|
||||
silenceDurationSeconds: 0.2,
|
||||
maxDrainSeconds: 1.0
|
||||
)
|
||||
|
||||
func testDrainFinishesAfterContinuousSilence() {
|
||||
let tracker = FlowCaptureDrainTracker()
|
||||
let start = Date().timeIntervalSince1970
|
||||
tracker.beginDrain(now: start)
|
||||
|
||||
tracker.noteAudio(samples: [0.001, 0.001], policy: policy, now: start + 0.05)
|
||||
|
||||
let silentAt = start + 0.1
|
||||
let decision = tracker.shouldFinish(policy: policy, now: silentAt + policy.silenceDurationSeconds)
|
||||
XCTAssertTrue(decision.finished)
|
||||
XCTAssertTrue(decision.endedBySilence)
|
||||
}
|
||||
|
||||
func testDrainFinishesAtMaxDurationEvenWithoutSilence() {
|
||||
let tracker = FlowCaptureDrainTracker()
|
||||
let start = Date().timeIntervalSince1970
|
||||
tracker.beginDrain(now: start)
|
||||
|
||||
tracker.noteAudio(samples: [0.5, 0.4], policy: policy, now: start + 0.05)
|
||||
tracker.noteAudio(samples: [0.45, 0.42], policy: policy, now: start + 0.4)
|
||||
|
||||
let decision = tracker.shouldFinish(policy: policy, now: start + policy.maxDrainSeconds)
|
||||
XCTAssertTrue(decision.finished)
|
||||
XCTAssertFalse(decision.endedBySilence)
|
||||
}
|
||||
|
||||
func testRMSDetectsAudibleSamples() {
|
||||
XCTAssertGreaterThan(
|
||||
FlowCaptureDrainTracker.rms(of: [0.2, 0.18, 0.15]),
|
||||
policy.silenceRMSThreshold
|
||||
)
|
||||
XCTAssertLessThan(
|
||||
FlowCaptureDrainTracker.rms(of: [0.001, 0.0005]),
|
||||
policy.silenceRMSThreshold
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -63,4 +63,33 @@ final class UtteranceStreamChunkerTests: XCTestCase {
|
||||
XCTAssertGreaterThanOrEqual(received.count, 2)
|
||||
XCTAssertTrue(received.last?.isLast == true)
|
||||
}
|
||||
|
||||
func testFinalChunkIncludesLateArrivingTailSamples() async {
|
||||
let config = FlowUtteranceChunkConfig(
|
||||
firstChunkDurationSeconds: 0.5,
|
||||
subsequentChunkDurationSeconds: 1.0,
|
||||
overlapDurationSeconds: 0,
|
||||
pauseExtensionMaxSeconds: 0,
|
||||
pauseRMSThreshold: 0.02,
|
||||
sampleRate: 1_000
|
||||
)
|
||||
let head = [Float](repeating: 0.05, count: 600)
|
||||
let tail = [Float](repeating: 0.08, count: 250)
|
||||
|
||||
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||
continuation.yield(AudioBufferSnapshot(samples: head, sampleRate: Double(config.sampleRate)))
|
||||
continuation.yield(AudioBufferSnapshot(samples: tail, sampleRate: Double(config.sampleRate)))
|
||||
continuation.finish()
|
||||
|
||||
var received: [UtteranceAudioChunk] = []
|
||||
for await chunk in UtteranceStreamChunker.chunks(from: stream, config: config) {
|
||||
received.append(chunk)
|
||||
}
|
||||
|
||||
guard let last = received.last else {
|
||||
return XCTFail("expected at least one chunk")
|
||||
}
|
||||
XCTAssertTrue(last.isLast)
|
||||
XCTAssertGreaterThanOrEqual(last.samples.count, tail.count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,24 @@ final class UtteranceTranscriptStitcherTests: XCTestCase {
|
||||
stitcher.append(index: 0, text: "第一段")
|
||||
XCTAssertEqual(stitcher.composed(), "第一段 第二段")
|
||||
}
|
||||
|
||||
func testComposedSafelyFallsBackWhenOverlapMergeShortensTooMuch() {
|
||||
var stitcher = UtteranceTranscriptStitcher()
|
||||
stitcher.append(index: 0, text: "今天天气很好我们")
|
||||
stitcher.append(index: 1, text: "去公园")
|
||||
let merged = stitcher.composed()
|
||||
let safe = stitcher.composedSafely()
|
||||
XCTAssertFalse(merged.isEmpty)
|
||||
XCTAssertFalse(safe.isEmpty)
|
||||
XCTAssertTrue(safe.contains("去公园"))
|
||||
}
|
||||
|
||||
func testRemoveLastSegmentSupportsMergedTailRetranscription() {
|
||||
var stitcher = UtteranceTranscriptStitcher()
|
||||
stitcher.append(index: 0, text: "第一段")
|
||||
stitcher.append(index: 1, text: "第二段")
|
||||
stitcher.removeLastSegment()
|
||||
stitcher.append(index: 1, text: "第二段合并")
|
||||
XCTAssertEqual(stitcher.composed(), "第一段 第二段合并")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user