merge: tail-drain and chunked ASR hardening into main

Resolve FlowContinuousCapture by combining tail-drain gate/drain logic
with flow-abcd route-change and interruption recovery.
This commit is contained in:
Rocky
2026-07-06 18:21:34 +08:00
14 changed files with 696 additions and 53 deletions
@@ -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(), "第一段 第二段合并")
}
}