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:
@@ -0,0 +1,130 @@
|
||||
// FlowCaptureTailDrain.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Tail-drain policy and silence tracking for utterance end. After the user
|
||||
// stops recording, capture keeps forwarding PCM until trailing speech drains
|
||||
// or a safety timeout elapses (symmetric to pre-roll at utterance start).
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Tunable tail-drain policy shared by Flow capture and preview dictation.
|
||||
public struct FlowCaptureTailDrainPolicy: Sendable, Equatable {
|
||||
/// RMS below this counts as silence while draining (16 kHz mono Float32).
|
||||
public let silenceRMSThreshold: Float
|
||||
/// Finish drain after this much continuous silence.
|
||||
public let silenceDurationSeconds: TimeInterval
|
||||
/// Hard cap so noisy environments cannot stall finalize forever.
|
||||
public let maxDrainSeconds: TimeInterval
|
||||
|
||||
public init(
|
||||
silenceRMSThreshold: Float,
|
||||
silenceDurationSeconds: TimeInterval,
|
||||
maxDrainSeconds: TimeInterval
|
||||
) {
|
||||
self.silenceRMSThreshold = silenceRMSThreshold
|
||||
self.silenceDurationSeconds = silenceDurationSeconds
|
||||
self.maxDrainSeconds = maxDrainSeconds
|
||||
}
|
||||
|
||||
public static let flowDefault = FlowCaptureTailDrainPolicy(
|
||||
silenceRMSThreshold: 0.015,
|
||||
silenceDurationSeconds: 0.25,
|
||||
maxDrainSeconds: 1.5
|
||||
)
|
||||
}
|
||||
|
||||
/// Metrics emitted when tail drain completes (for diagnostics and tests).
|
||||
public struct FlowCaptureDrainReport: Sendable, Equatable {
|
||||
public let drainDurationSeconds: Double
|
||||
public let endedBySilence: Bool
|
||||
public let tailSampleCount: Int
|
||||
|
||||
public init(
|
||||
drainDurationSeconds: Double,
|
||||
endedBySilence: Bool,
|
||||
tailSampleCount: Int
|
||||
) {
|
||||
self.drainDurationSeconds = drainDurationSeconds
|
||||
self.endedBySilence = endedBySilence
|
||||
self.tailSampleCount = tailSampleCount
|
||||
}
|
||||
|
||||
public static let skipped = FlowCaptureDrainReport(
|
||||
drainDurationSeconds: 0,
|
||||
endedBySilence: false,
|
||||
tailSampleCount: 0
|
||||
)
|
||||
}
|
||||
|
||||
/// Thread-safe silence tracker used while draining trailing audio.
|
||||
public final class FlowCaptureDrainTracker: @unchecked Sendable {
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
private var drainStartedAt: TimeInterval?
|
||||
private var lastAudibleAt: TimeInterval?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func reset() {
|
||||
lock.withLock {
|
||||
drainStartedAt = nil
|
||||
lastAudibleAt = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func beginDrain(now: TimeInterval = Date().timeIntervalSince1970) {
|
||||
lock.withLock {
|
||||
drainStartedAt = now
|
||||
lastAudibleAt = now
|
||||
}
|
||||
}
|
||||
|
||||
public func noteAudio(
|
||||
samples: [Float],
|
||||
policy: FlowCaptureTailDrainPolicy,
|
||||
now: TimeInterval = Date().timeIntervalSince1970
|
||||
) {
|
||||
guard !samples.isEmpty else { return }
|
||||
let rms = Self.rms(of: samples)
|
||||
lock.withLock {
|
||||
guard drainStartedAt != nil else { return }
|
||||
if rms >= policy.silenceRMSThreshold {
|
||||
lastAudibleAt = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func shouldFinish(
|
||||
policy: FlowCaptureTailDrainPolicy,
|
||||
now: TimeInterval = Date().timeIntervalSince1970
|
||||
) -> (finished: Bool, endedBySilence: Bool) {
|
||||
lock.withLock {
|
||||
guard let started = drainStartedAt else {
|
||||
return (true, false)
|
||||
}
|
||||
let audible = lastAudibleAt ?? started
|
||||
if now - started >= policy.maxDrainSeconds {
|
||||
return (true, false)
|
||||
}
|
||||
if now - audible >= policy.silenceDurationSeconds {
|
||||
return (true, true)
|
||||
}
|
||||
return (false, false)
|
||||
}
|
||||
}
|
||||
|
||||
public func elapsedSeconds(now: TimeInterval = Date().timeIntervalSince1970) -> Double {
|
||||
lock.withLock {
|
||||
guard let started = drainStartedAt else { return 0 }
|
||||
return max(0, now - started)
|
||||
}
|
||||
}
|
||||
|
||||
public static func rms(of samples: [Float]) -> Float {
|
||||
guard !samples.isEmpty else { return 0 }
|
||||
var sum: Float = 0
|
||||
for sample in samples {
|
||||
sum += sample * sample
|
||||
}
|
||||
return sqrtf(sum / Float(samples.count))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// FlowPipelineDiagnostics.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Structured Flow pipeline metrics for Console.app filtering.
|
||||
|
||||
import Foundation
|
||||
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)"
|
||||
)
|
||||
}
|
||||
|
||||
public static func logChunkFinalize(
|
||||
chunkCount: Int,
|
||||
lastChunkSamples: Int,
|
||||
stitchedLength: Int,
|
||||
chunkWarnings: Int
|
||||
) {
|
||||
OSGLog.flow.info(
|
||||
"chunkPipeline chunks=\(chunkCount) lastChunkSamples=\(lastChunkSamples) " +
|
||||
"stitchedLen=\(stitchedLength) warnings=\(chunkWarnings)"
|
||||
)
|
||||
}
|
||||
|
||||
public static func logStitcherSafeFallback(naiveLength: Int, mergedLength: Int) {
|
||||
OSGLog.asr.warning(
|
||||
"stitcher safe fallback naive=\(naiveLength) merged=\(mergedLength)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ public struct UtteranceTranscriptStitcher: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the highest-index segment (used when re-transcribing a merged tail chunk).
|
||||
public mutating func removeLastSegment() {
|
||||
guard !segments.isEmpty else { return }
|
||||
segments.removeLast()
|
||||
}
|
||||
|
||||
public func composed() -> String {
|
||||
guard let first = segments.first else { return "" }
|
||||
var result = first.text
|
||||
@@ -30,6 +36,21 @@ public struct UtteranceTranscriptStitcher: Sendable {
|
||||
return result
|
||||
}
|
||||
|
||||
/// Prefer overlap-aware merge, but fall back to naive join when dedup would drop real content.
|
||||
public func composedSafely() -> String {
|
||||
let merged = composed()
|
||||
guard segments.count >= 2 else { return merged }
|
||||
let naive = segments.map(\.text).joined(separator: " ")
|
||||
if merged.count + 16 < naive.count {
|
||||
FlowPipelineDiagnostics.logStitcherSafeFallback(
|
||||
naiveLength: naive.count,
|
||||
mergedLength: merged.count
|
||||
)
|
||||
return naive
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/// Merge `next` onto `previous`, dropping duplicated suffix/prefix overlap.
|
||||
public static func mergeWithOverlap(previous: String, next: String) -> String {
|
||||
let trimmedNext = next.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
Reference in New Issue
Block a user