Browse Source

fix(input-box): eliminate trailing word duplication via aligned index mapping & synchronous paste guard (v2.08)

main
Ali Alavi 1 week ago
parent
commit
eedd895c70
  1. 2
      Info.plist
  2. 167
      src/InputBoxManager.swift
  3. 4
      src/TextProcessor.swift

2
Info.plist

@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.07</string>
<string>2.08</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>

167
src/InputBoxManager.swift

@ -14,7 +14,7 @@ public final class InputBoxManager {
private let pasteQueue = DispatchQueue(label: "com.soniox.pasteQueue", qos: .userInteractive)
private static let punctRegex: NSRegularExpression = {
return try! NSRegularExpression(pattern: #"[.,!?:;،؛؟"'()\[\]{}«»–—\-]"#, options: [])
return try! NSRegularExpression(pattern: #"[.,!?:;،؛؟"'()\[\]{}«»–—\-…]"#, options: [])
}()
private init() {}
@ -101,11 +101,7 @@ public final class InputBoxManager {
self.lock.unlock()
guard valid else { return }
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(textToPaste, forType: .string)
self.postCleanCmdV()
usleep(10000) // Ultra-fast 10ms pacing between streaming chunks
self.pasteSynchronously(textToPaste)
}
return
}
@ -138,11 +134,7 @@ public final class InputBoxManager {
self.lock.unlock()
guard valid else { return }
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(toFlush, forType: .string)
self.postCleanCmdV()
usleep(10000)
self.pasteSynchronously(toFlush)
}
return
}
@ -169,11 +161,7 @@ public final class InputBoxManager {
self.lock.unlock()
guard valid else { return }
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(textToPaste, forType: .string)
self.postCleanCmdV()
usleep(10000)
self.pasteSynchronously(textToPaste)
}
return
}
@ -201,11 +189,7 @@ public final class InputBoxManager {
self.lock.unlock()
guard valid else { return }
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(toFlush, forType: .string)
self.postCleanCmdV()
usleep(10000)
self.pasteSynchronously(toFlush)
}
return
}
@ -213,18 +197,13 @@ public final class InputBoxManager {
lock.unlock()
}
/// Commits the final speech result: ensures 100% complete text in input box & clipboard
/// Commits the final speech result: ensures 100% complete text in input box & clipboard without ANY duplication
public func commitFinalText(fullText: String, addTrailingSpace: Bool) {
let trimmed = fullText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let textWithTrailing = addTrailingSpace ? (trimmed + " ") : trimmed
// Always place the full processed text on the system clipboard
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(textWithTrailing, forType: .string)
lock.lock()
let expectedGen = sessionGeneration
// Flush any pending buffer if recording ended before prefix resolution
@ -237,11 +216,14 @@ public final class InputBoxManager {
accumulatedStreamedText += pending
}
}
let streamed = accumulatedStreamedText
let rawStreamed = accumulatedStreamedText
accumulatedStreamedText = ""
lock.unlock()
let cleanStreamed = streamed.trimmingCharacters(in: .whitespacesAndNewlines)
// CRITICAL: Process the streamed text through the EXACT same TextProcessor pipeline as fullText!
// This guarantees that numbers (۱ -> 1), ellipses (... -> . . .), and punctuation tokens have identical word counts & tokens!
let processedStreamed = rawStreamed.isEmpty ? "" : TextProcessor.shared.process(text: rawStreamed)
let cleanStreamed = processedStreamed.trimmingCharacters(in: .whitespacesAndNewlines)
pasteQueue.async { [weak self] in
guard let self = self else { return }
@ -250,7 +232,6 @@ public final class InputBoxManager {
self.lock.unlock()
guard valid else { return }
// Safe 250ms clipboard restore with generation cancellation to prevent double-paste in heavy apps
let restoreClipboardFullText = { [weak self] in
guard let self = self else { return }
let work = DispatchWorkItem { [weak self] in
@ -267,7 +248,7 @@ public final class InputBoxManager {
self.clipboardRestoreWorkItem?.cancel()
self.clipboardRestoreWorkItem = work
self.lock.unlock()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.30, execute: work)
}
// Case 1: Nothing was streamed live -> Paste entire text
@ -283,10 +264,7 @@ public final class InputBoxManager {
// Case 2: Exactly matches streamed text
if cleanStreamed == trimmed {
if addTrailingSpace {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(" ", forType: .string)
self.postCleanCmdV()
self.pasteSynchronously(" ")
}
restoreClipboardFullText()
return
@ -296,29 +274,34 @@ public final class InputBoxManager {
if trimmed.count > cleanStreamed.count && trimmed.hasPrefix(cleanStreamed) {
let suffix = String(trimmed.dropFirst(cleanStreamed.count))
let suffixToPaste = addTrailingSpace ? (suffix + " ") : suffix
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(suffixToPaste, forType: .string)
self.postCleanCmdV()
self.pasteSynchronously(suffixToPaste)
restoreClipboardFullText()
return
}
// Case 4: Intelligent Word-level Tail-Anchor Matching
let streamedWords = cleanStreamed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
let finalWords = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
guard !finalWords.isEmpty else {
restoreClipboardFullText()
return
}
// Case 4: Word-level Tail-Anchor Matching with 1-to-1 Aligned Index Mapping
let rawStreamedWords = cleanStreamed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
let rawFinalWords = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
let cleanWord: (String) -> String = { w in
let noZwnj = w.replacingOccurrences(of: "\u{200C}", with: "").replacingOccurrences(of: "\u{200D}", with: "")
return InputBoxManager.stripPunctuation(noZwnj).lowercased()
}
let normStreamed = streamedWords.map(cleanWord).filter { !$0.isEmpty }
let normFinal = finalWords.map(cleanWord).filter { !$0.isEmpty }
// Keep ONLY words that contain actual alphanumeric/Persian letters so standalone punctuation (".", "...") NEVER shifts indices!
let streamedPairs: [(raw: String, norm: String)] = rawStreamedWords.compactMap { w in
let nw = cleanWord(w)
return nw.isEmpty ? nil : (w, nw)
}
let finalPairs: [(raw: String, norm: String)] = rawFinalWords.compactMap { w in
let nw = cleanWord(w)
return nw.isEmpty ? nil : (w, nw)
}
guard !finalPairs.isEmpty else {
restoreClipboardFullText()
return
}
let wordsMatch: (String, String) -> Bool = { w1, w2 in
if w1 == w2 { return true }
@ -328,83 +311,76 @@ public final class InputBoxManager {
return false
}
var unstreamedStartIndex: Int? = nil
var unstreamedPairIndex: Int? = nil
// Strategy 4A: Search for 3-word tail anchor from streamed text in final words
if normStreamed.count >= 3 {
let a1 = normStreamed[normStreamed.count - 3]
let a2 = normStreamed[normStreamed.count - 2]
let a3 = normStreamed[normStreamed.count - 1]
for j in stride(from: normFinal.count - 1, through: 2, by: -1) {
if wordsMatch(normFinal[j], a3) && wordsMatch(normFinal[j - 1], a2) && wordsMatch(normFinal[j - 2], a1) {
unstreamedStartIndex = j + 1
// Strategy 4A: 3-word tail anchor
if streamedPairs.count >= 3 {
let a1 = streamedPairs[streamedPairs.count - 3].norm
let a2 = streamedPairs[streamedPairs.count - 2].norm
let a3 = streamedPairs[streamedPairs.count - 1].norm
for j in stride(from: finalPairs.count - 1, through: 2, by: -1) {
if wordsMatch(finalPairs[j].norm, a3) && wordsMatch(finalPairs[j - 1].norm, a2) && wordsMatch(finalPairs[j - 2].norm, a1) {
unstreamedPairIndex = j + 1
break
}
}
}
// Strategy 4B: Search for 2-word tail anchor
if unstreamedStartIndex == nil && normStreamed.count >= 2 {
let a1 = normStreamed[normStreamed.count - 2]
let a2 = normStreamed[normStreamed.count - 1]
for j in stride(from: normFinal.count - 1, through: 1, by: -1) {
if wordsMatch(normFinal[j], a2) && wordsMatch(normFinal[j - 1], a1) {
unstreamedStartIndex = j + 1
// Strategy 4B: 2-word tail anchor
if unstreamedPairIndex == nil && streamedPairs.count >= 2 {
let a1 = streamedPairs[streamedPairs.count - 2].norm
let a2 = streamedPairs[streamedPairs.count - 1].norm
for j in stride(from: finalPairs.count - 1, through: 1, by: -1) {
if wordsMatch(finalPairs[j].norm, a2) && wordsMatch(finalPairs[j - 1].norm, a1) {
unstreamedPairIndex = j + 1
break
}
}
}
// Strategy 4C: Search for 1-word tail anchor in recent final words
if unstreamedStartIndex == nil && !normStreamed.isEmpty {
let a1 = normStreamed[normStreamed.count - 1]
let searchStart = max(0, normFinal.count - max(15, normStreamed.count))
for j in stride(from: normFinal.count - 1, through: searchStart, by: -1) {
if wordsMatch(normFinal[j], a1) {
unstreamedStartIndex = j + 1
// Strategy 4C: 1-word tail anchor
if unstreamedPairIndex == nil && !streamedPairs.isEmpty {
let a1 = streamedPairs[streamedPairs.count - 1].norm
let searchStart = max(0, finalPairs.count - max(15, streamedPairs.count))
for j in stride(from: finalPairs.count - 1, through: searchStart, by: -1) {
if wordsMatch(finalPairs[j].norm, a1) {
unstreamedPairIndex = j + 1
break
}
}
}
// Strategy 4D: Forward prefix match fallback
if unstreamedStartIndex == nil {
if unstreamedPairIndex == nil {
var prefixMatchCount = 0
for i in 0..<min(normStreamed.count, normFinal.count) {
if wordsMatch(normStreamed[i], normFinal[i]) {
for i in 0..<min(streamedPairs.count, finalPairs.count) {
if wordsMatch(streamedPairs[i].norm, finalPairs[i].norm) {
prefixMatchCount = i + 1
} else {
break
}
}
if prefixMatchCount > 0 {
unstreamedStartIndex = prefixMatchCount
unstreamedPairIndex = prefixMatchCount
}
}
// Guaranteed Safety Fallback: If no anchor matched, point to streamedWords.count
if unstreamedStartIndex == nil {
unstreamedStartIndex = min(streamedWords.count, finalWords.count)
// Guaranteed Safety Fallback: aligned 1-to-1 on content-bearing pairs
if unstreamedPairIndex == nil {
unstreamedPairIndex = min(streamedPairs.count, finalPairs.count)
}
// Suffix Evaluation: Always paste all remaining unstreamed words
if let startIndex = unstreamedStartIndex {
if startIndex < finalWords.count {
let remainingWords = Array(finalWords.dropFirst(startIndex))
if let startIndex = unstreamedPairIndex {
if startIndex < finalPairs.count {
let remainingWords = finalPairs.dropFirst(startIndex).map { $0.raw }
let suffix = remainingWords.joined(separator: " ")
let suffixToPaste = addTrailingSpace ? (" " + suffix + " ") : (" " + suffix)
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(suffixToPaste, forType: .string)
self.postCleanCmdV()
self.pasteSynchronously(suffixToPaste)
restoreClipboardFullText()
return
} else {
if addTrailingSpace {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(" ", forType: .string)
self.postCleanCmdV()
self.pasteSynchronously(" ")
}
restoreClipboardFullText()
return
@ -414,6 +390,15 @@ public final class InputBoxManager {
}
}
/// Places text on NSPasteboard, posts Cmd+V, and waits 45ms so target app consumes this chunk BEFORE next clipboard write!
private func pasteSynchronously(_ text: String) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(text, forType: .string)
postCleanCmdV()
usleep(45000) // 45ms guard ensures browser/app reads NSPasteboard before next chunk overwrites it
}
private func postCleanCmdV() {
let src = CGEventSource(stateID: .privateState)
let vKeyCode: CGKeyCode = 0x09 // 'v'

4
src/TextProcessor.swift

@ -261,9 +261,11 @@ public final class TextProcessor {
for p in punctuationList {
t = t.replacingOccurrences(of: " " + p, with: p)
}
for p in [".", "،", "؛", "؟", "!"] {
t = t.replacingOccurrences(of: "...", with: "…")
for p in [".", "،", "؛", "؟", "!", "…"] {
t = t.replacingOccurrences(of: p, with: p + " ")
}
t = t.replacingOccurrences(of: "…", with: "...")
let speechFixes: [(String, String)] = [
(" ماا ", " ما "),
(" اینپیگیری ", " این پیگیری "),

Loading…
Cancel
Save