120 lines
4.6 KiB
Swift
120 lines
4.6 KiB
Swift
// ProgressiveDictationTranscriptAccumulator.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Merges progressive `DictationTranscriber` results into one transcript.
|
|
// Short-form presets may emit a new time range after ~30 s; treating the
|
|
// latest partial as the full transcript drops earlier segments.
|
|
|
|
import Foundation
|
|
import CoreMedia
|
|
|
|
/// Combines volatile partials and finalized segments from
|
|
/// `DictationTranscriber.results` into a single growing transcript.
|
|
public struct ProgressiveDictationTranscriptAccumulator: Sendable {
|
|
|
|
private struct Segment: Sendable {
|
|
let startSeconds: Double
|
|
var text: String
|
|
}
|
|
|
|
private var segments: [Segment] = []
|
|
private var lastEmitted = ""
|
|
|
|
public init() {}
|
|
|
|
/// Ingest one analyzer result. Returns a non-nil full transcript when the
|
|
/// composed text changed since the previous emission.
|
|
public mutating func ingest(range: CMTimeRange, text: String) -> String? {
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
let start = range.start.seconds
|
|
|
|
if let idx = segments.lastIndex(where: { abs($0.startSeconds - start) < 0.001 }) {
|
|
// Same audio window — volatile refinement of the current segment.
|
|
segments[idx].text = trimmed
|
|
} else if let last = segments.last,
|
|
let revision = Self.revisionText(
|
|
previous: last.text,
|
|
candidate: trimmed,
|
|
startDelta: abs(last.startSeconds - start)
|
|
) {
|
|
// Cumulative progressive update with a small range drift. SpeechAnalyzer
|
|
// may add punctuation while nudging the range start; treat that as a
|
|
// refinement, not a new sentence.
|
|
segments[segments.count - 1].text = revision
|
|
} else {
|
|
// New time range — append instead of replacing earlier speech.
|
|
segments.append(Segment(startSeconds: start, text: trimmed))
|
|
}
|
|
|
|
let full = composedText()
|
|
guard full != lastEmitted else { return nil }
|
|
lastEmitted = full
|
|
return full
|
|
}
|
|
|
|
/// Final composed transcript after the results stream finishes.
|
|
public mutating func finalize() -> String {
|
|
let full = composedText()
|
|
lastEmitted = full
|
|
return full
|
|
}
|
|
|
|
private func composedText() -> String {
|
|
segments.reduce(into: "") { partial, segment in
|
|
partial = DictationTextComposer.compose(anchor: partial, live: segment.text)
|
|
}
|
|
}
|
|
|
|
private static func revisionText(
|
|
previous: String,
|
|
candidate: String,
|
|
startDelta: Double
|
|
) -> String? {
|
|
let normalizedPrevious = DictationTextComposer.normalizeForOverlap(previous)
|
|
let normalizedCandidate = DictationTextComposer.normalizeForOverlap(candidate)
|
|
guard !normalizedPrevious.isEmpty, !normalizedCandidate.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
if candidate.hasPrefix(previous) || previous.hasPrefix(candidate) {
|
|
return candidate.count >= previous.count ? candidate : previous
|
|
}
|
|
|
|
// Avoid collapsing genuinely separate long ranges; this only handles
|
|
// volatile corrections whose time range start drifted slightly.
|
|
guard startDelta <= 1.0 else { return nil }
|
|
|
|
if normalizedCandidate.hasPrefix(normalizedPrevious)
|
|
|| normalizedPrevious.hasPrefix(normalizedCandidate)
|
|
|| normalizedCandidate.contains(normalizedPrevious)
|
|
|| normalizedPrevious.contains(normalizedCandidate) {
|
|
return normalizedCandidate.count >= normalizedPrevious.count ? candidate : previous
|
|
}
|
|
|
|
let overlap = longestNormalizedOverlap(
|
|
previous: normalizedPrevious,
|
|
candidate: normalizedCandidate
|
|
)
|
|
let shorter = min(normalizedPrevious.count, normalizedCandidate.count)
|
|
guard shorter >= 4, Double(overlap) / Double(shorter) >= 0.8 else {
|
|
return nil
|
|
}
|
|
return normalizedCandidate.count >= normalizedPrevious.count ? candidate : previous
|
|
}
|
|
|
|
private static func longestNormalizedOverlap(previous: String, candidate: String) -> Int {
|
|
let previousChars = Array(previous)
|
|
let candidateChars = Array(candidate)
|
|
let maxProbe = min(64, previousChars.count, candidateChars.count)
|
|
guard maxProbe > 0 else { return 0 }
|
|
for length in stride(from: maxProbe, through: 1, by: -1) {
|
|
if previousChars.suffix(length).elementsEqual(candidateChars.prefix(length)) {
|
|
return length
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
}
|