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)] = [ (" ماا ", " ما "), (" اینپیگیری ", " این پیگیری "),