9f308fadd2
Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
161 lines
6.6 KiB
Swift
161 lines
6.6 KiB
Swift
// UtteranceTranscriptStitcher.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Orders pipelined chunk transcripts and merges overlap at boundaries.
|
|
|
|
import Foundation
|
|
|
|
public struct UtteranceTranscriptStitcher: Sendable {
|
|
private var segments: [(index: Int, text: String, trailingPauseSeconds: Double)] = []
|
|
|
|
public init() {}
|
|
|
|
public mutating func append(
|
|
index: Int,
|
|
text: String,
|
|
trailingPauseSeconds: Double = 0
|
|
) {
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
if let existing = segments.firstIndex(where: { $0.index == index }) {
|
|
segments[existing].text = trimmed
|
|
segments[existing].trailingPauseSeconds = trailingPauseSeconds
|
|
} else {
|
|
segments.append((index, trimmed, trailingPauseSeconds))
|
|
segments.sort { $0.index < $1.index }
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
for segment in segments.dropFirst() {
|
|
result = Self.mergeWithOverlap(previous: result, next: segment.text)
|
|
}
|
|
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
|
|
}
|
|
|
|
/// Final text for LLM processing only. Partial preview continues to use
|
|
/// `composedSafely()` and therefore never exposes internal markers.
|
|
public func composedWithPauseMarks(threshold: Double = 0.45) -> String {
|
|
guard let first = segments.first else { return "" }
|
|
let safePlain = composedSafely()
|
|
let mergedPlain = composed()
|
|
if safePlain != mergedPlain {
|
|
return naiveWithPauseMarks(threshold: threshold)
|
|
}
|
|
|
|
var plain = first.text
|
|
var marked = first.text
|
|
var previous = first
|
|
for segment in segments.dropFirst() {
|
|
let nextPlain = Self.mergeWithOverlap(previous: plain, next: segment.text)
|
|
let suffix = String(nextPlain.dropFirst(min(plain.count, nextPlain.count)))
|
|
if previous.trailingPauseSeconds >= threshold, !suffix.isEmpty {
|
|
marked += " \(Self.pauseMarker(previous.trailingPauseSeconds)) "
|
|
marked += suffix.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
} else {
|
|
marked += suffix
|
|
}
|
|
plain = nextPlain
|
|
previous = segment
|
|
}
|
|
return marked
|
|
}
|
|
|
|
/// Merge `next` onto `previous`, dropping duplicated suffix/prefix overlap.
|
|
public static func mergeWithOverlap(previous: String, next: String) -> String {
|
|
let trimmedNext = next.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmedNext.isEmpty else { return previous }
|
|
guard !previous.isEmpty else { return trimmedNext }
|
|
|
|
// Character-granular probe — works for CJK without word boundaries.
|
|
let prevChars = Array(previous)
|
|
let nextChars = Array(trimmedNext)
|
|
let maxProbe = min(64, prevChars.count, nextChars.count)
|
|
if maxProbe > 0 {
|
|
for length in stride(from: maxProbe, through: 1, by: -1) {
|
|
let suffix = prevChars.suffix(length)
|
|
let prefix = nextChars.prefix(length)
|
|
if suffix.elementsEqual(prefix) {
|
|
return previous + String(nextChars.dropFirst(length))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Punctuation-insensitive CJK overlap (e.g. "很好," + "很好继续").
|
|
let normalizedPrev = TranscriptOverlapUtilities.normalized(previous)
|
|
let normalizedNext = TranscriptOverlapUtilities.normalized(trimmedNext)
|
|
let nPrev = Array(normalizedPrev)
|
|
let nNext = Array(normalizedNext)
|
|
let normProbe = min(64, nPrev.count, nNext.count)
|
|
if normProbe > 0 {
|
|
for length in stride(from: normProbe, through: 2, by: -1) {
|
|
if nPrev.suffix(length).elementsEqual(nNext.prefix(length)) {
|
|
// Map normalized overlap length back to raw `next` drop count.
|
|
let drop = TranscriptOverlapUtilities.rawDropCount(
|
|
in: trimmedNext,
|
|
normalizedPrefixLength: length
|
|
)
|
|
return previous + String(trimmedNext.dropFirst(drop))
|
|
}
|
|
}
|
|
}
|
|
|
|
// English / spaced languages.
|
|
let maxWordProbe = min(6, previous.split(separator: " ").count, trimmedNext.split(separator: " ").count)
|
|
if maxWordProbe > 0 {
|
|
let prevWords = previous.split(separator: " ", omittingEmptySubsequences: true)
|
|
let nextWords = trimmedNext.split(separator: " ", omittingEmptySubsequences: true)
|
|
for wordCount in stride(from: maxWordProbe, through: 1, by: -1) {
|
|
if prevWords.suffix(wordCount).elementsEqual(nextWords.prefix(wordCount)) {
|
|
let mergedPrefix = nextWords.dropFirst(wordCount).joined(separator: " ")
|
|
if mergedPrefix.isEmpty { return previous }
|
|
if previous.last == " " || previous.last == "\n" {
|
|
return previous + mergedPrefix
|
|
}
|
|
return previous + " " + mergedPrefix
|
|
}
|
|
}
|
|
}
|
|
|
|
return DictationTextComposer.compose(anchor: previous, live: trimmedNext)
|
|
}
|
|
|
|
private func naiveWithPauseMarks(threshold: Double) -> String {
|
|
var pieces: [String] = []
|
|
for (offset, segment) in segments.enumerated() {
|
|
pieces.append(segment.text)
|
|
if segment.trailingPauseSeconds >= threshold, offset < segments.count - 1 {
|
|
pieces.append(Self.pauseMarker(segment.trailingPauseSeconds))
|
|
}
|
|
}
|
|
return pieces.joined(separator: " ")
|
|
}
|
|
|
|
private static func pauseMarker(_ seconds: Double) -> String {
|
|
"⟨\(String(format: "%.1f", seconds))s⟩"
|
|
}
|
|
}
|