diff --git a/OSGKeyboardShared/Utilities/DictationTextComposer.swift b/OSGKeyboardShared/Utilities/DictationTextComposer.swift index f3b1313..9fd508a 100644 --- a/OSGKeyboardShared/Utilities/DictationTextComposer.swift +++ b/OSGKeyboardShared/Utilities/DictationTextComposer.swift @@ -11,7 +11,81 @@ public enum DictationTextComposer { let trimmed = live.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return anchor } if anchor.isEmpty { return trimmed } + if let merged = mergeOverlapping(anchor: anchor, live: trimmed) { + return merged + } + if shouldConcatenateWithoutSpace(anchor: anchor, live: trimmed) { + return anchor + trimmed + } if anchor.last == " " || anchor.last == "\n" { return anchor + trimmed } return anchor + " " + trimmed } + + /// Drop duplicated suffix/prefix overlap before falling back to spaced composition. + /// This catches progressive ASR revisions such as "可用性" + "可用性已经". + private static func mergeOverlapping(anchor: String, live: String) -> String? { + let anchorChars = Array(anchor) + let liveChars = Array(live) + let maxProbe = min(64, anchorChars.count, liveChars.count) + if maxProbe > 0 { + for length in stride(from: maxProbe, through: 2, by: -1) { + if anchorChars.suffix(length).elementsEqual(liveChars.prefix(length)) { + return anchor + String(liveChars.dropFirst(length)) + } + } + } + + let normalizedAnchor = normalizeForOverlap(anchor) + let normalizedLive = normalizeForOverlap(live) + let anchorNormChars = Array(normalizedAnchor) + let liveNormChars = Array(normalizedLive) + let normProbe = min(64, anchorNormChars.count, liveNormChars.count) + if normProbe > 0 { + for length in stride(from: normProbe, through: 3, by: -1) { + if anchorNormChars.suffix(length).elementsEqual(liveNormChars.prefix(length)) { + let drop = rawDropCount(in: live, normalizedPrefixLength: length) + return anchor + String(live.dropFirst(drop)) + } + } + } + + return nil + } + + private static func shouldConcatenateWithoutSpace(anchor: String, live: String) -> Bool { + guard let last = anchor.unicodeScalars.last, + let first = live.unicodeScalars.first else { + return false + } + return isCJK(last) && isCJK(first) + } + + static func normalizeForOverlap(_ text: String) -> String { + text.unicodeScalars.filter { + !CharacterSet.whitespacesAndNewlines.contains($0) + && !CharacterSet.punctuationCharacters.contains($0) + }.map { Character($0) }.reduce(into: "") { $0.append($1) } + } + + private static func rawDropCount(in text: String, normalizedPrefixLength: Int) -> Int { + var normalizedCount = 0 + var rawIndex = text.startIndex + while rawIndex < text.endIndex, normalizedCount < normalizedPrefixLength { + let character = text[rawIndex] + if !character.isWhitespace, !character.isPunctuation { + normalizedCount += 1 + } + rawIndex = text.index(after: rawIndex) + } + return text.distance(from: text.startIndex, to: rawIndex) + } + + private static func isCJK(_ scalar: UnicodeScalar) -> Bool { + switch scalar.value { + case 0x3400...0x4DBF, 0x4E00...0x9FFF, 0xF900...0xFAFF: + return true + default: + return false + } + } } diff --git a/OSGKeyboardShared/Utilities/ProgressiveDictationTranscriptAccumulator.swift b/OSGKeyboardShared/Utilities/ProgressiveDictationTranscriptAccumulator.swift index 30d61ff..2728437 100644 --- a/OSGKeyboardShared/Utilities/ProgressiveDictationTranscriptAccumulator.swift +++ b/OSGKeyboardShared/Utilities/ProgressiveDictationTranscriptAccumulator.swift @@ -34,10 +34,15 @@ public struct ProgressiveDictationTranscriptAccumulator: Sendable { // Same audio window — volatile refinement of the current segment. segments[idx].text = trimmed } else if let last = segments.last, - trimmed.hasPrefix(last.text) || last.text.hasPrefix(trimmed) { - // Cumulative progressive update without a range change. - let longer = trimmed.count >= last.text.count ? trimmed : last.text - segments[segments.count - 1].text = longer + 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)) @@ -61,4 +66,54 @@ public struct ProgressiveDictationTranscriptAccumulator: Sendable { 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 + } } diff --git a/OSGKeyboardTests/ProgressiveDictationTranscriptAccumulatorTests.swift b/OSGKeyboardTests/ProgressiveDictationTranscriptAccumulatorTests.swift index bf8bc60..7ecbfc4 100644 --- a/OSGKeyboardTests/ProgressiveDictationTranscriptAccumulatorTests.swift +++ b/OSGKeyboardTests/ProgressiveDictationTranscriptAccumulatorTests.swift @@ -31,7 +31,7 @@ final class ProgressiveDictationTranscriptAccumulatorTests: XCTestCase { _ = acc.ingest(range: r0, text: "前三十秒的内容") _ = acc.ingest(range: r30, text: "后二十秒的内容") - XCTAssertEqual(acc.finalize(), "前三十秒的内容 后二十秒的内容") + XCTAssertEqual(acc.finalize(), "前三十秒的内容后二十秒的内容") } func testDuplicateEmissionIsSuppressed() { @@ -41,4 +41,32 @@ final class ProgressiveDictationTranscriptAccumulatorTests: XCTestCase { XCTAssertNotNil(acc.ingest(range: r0, text: "hello")) XCTAssertNil(acc.ingest(range: r0, text: "hello")) } + + func testPunctuationRevisionWithRangeDriftReplacesPriorSegment() { + var acc = ProgressiveDictationTranscriptAccumulator() + + _ = acc.ingest( + range: range(start: 0.0, duration: 2.5), + text: "先这样用着就算目前的可用性" + ) + _ = acc.ingest( + range: range(start: 0.25, duration: 2.5), + text: "先这样用着,就算目前的可用性已经" + ) + _ = acc.ingest( + range: range(start: 2.8, duration: 2.5), + text: "提升很多了" + ) + + XCTAssertEqual(acc.finalize(), "先这样用着,就算目前的可用性已经提升很多了") + } + + func testComposerDropsOverlappingSuffixAndPrefix() { + let composed = DictationTextComposer.compose( + anchor: "先这样用着,就算目前的可用性已经", + live: "可用性已经提升很多了" + ) + + XCTAssertEqual(composed, "先这样用着,就算目前的可用性已经提升很多了") + } }