diff --git a/Info.plist b/Info.plist
index 2a1e688..d378e2c 100644
--- a/Info.plist
+++ b/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2.07
+ 2.08
CFBundleVersion
1
LSMinimumSystemVersion
diff --git a/src/InputBoxManager.swift b/src/InputBoxManager.swift
index 18c0306..5d419b9 100644
--- a/src/InputBoxManager.swift
+++ b/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.. 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'
diff --git a/src/TextProcessor.swift b/src/TextProcessor.swift
index 2d1d5cd..876f9f0 100644
--- a/src/TextProcessor.swift
+++ b/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)] = [
(" ماا ", " ما "),
(" اینپیگیری ", " این پیگیری "),