checkpoint before checking out cursor/flow-abcd-host-return-2b89
This commit is contained in:
@@ -11,7 +11,81 @@ public enum DictationTextComposer {
|
|||||||
let trimmed = live.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = live.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return anchor }
|
guard !trimmed.isEmpty else { return anchor }
|
||||||
if anchor.isEmpty { return trimmed }
|
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 }
|
if anchor.last == " " || anchor.last == "\n" { return anchor + trimmed }
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,15 @@ public struct ProgressiveDictationTranscriptAccumulator: Sendable {
|
|||||||
// Same audio window — volatile refinement of the current segment.
|
// Same audio window — volatile refinement of the current segment.
|
||||||
segments[idx].text = trimmed
|
segments[idx].text = trimmed
|
||||||
} else if let last = segments.last,
|
} else if let last = segments.last,
|
||||||
trimmed.hasPrefix(last.text) || last.text.hasPrefix(trimmed) {
|
let revision = Self.revisionText(
|
||||||
// Cumulative progressive update without a range change.
|
previous: last.text,
|
||||||
let longer = trimmed.count >= last.text.count ? trimmed : last.text
|
candidate: trimmed,
|
||||||
segments[segments.count - 1].text = longer
|
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 {
|
} else {
|
||||||
// New time range — append instead of replacing earlier speech.
|
// New time range — append instead of replacing earlier speech.
|
||||||
segments.append(Segment(startSeconds: start, text: trimmed))
|
segments.append(Segment(startSeconds: start, text: trimmed))
|
||||||
@@ -61,4 +66,54 @@ public struct ProgressiveDictationTranscriptAccumulator: Sendable {
|
|||||||
partial = DictationTextComposer.compose(anchor: partial, live: segment.text)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,4 +41,32 @@ final class ProgressiveDictationTranscriptAccumulatorTests: XCTestCase {
|
|||||||
XCTAssertNotNil(acc.ingest(range: r0, text: "hello"))
|
XCTAssertNotNil(acc.ingest(range: r0, text: "hello"))
|
||||||
XCTAssertNil(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, "先这样用着,就算目前的可用性已经提升很多了")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user