|
|
|
@ -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' |
|
|
|
|