Files
OSGKeyboard/OSGKeyboardShared/Utilities/ProgressiveDictationTranscriptAccumulator.swift
T

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
}
}