diff --git a/package.sh b/package.sh old mode 100644 new mode 100755 diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 0a9afcd..9a8fe16 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -9,6 +9,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { private var isBusyFinalizing = false private var currentAudioLevel: Float = 0.0 private var latestPartialText: String? = nil + private var pendingStopWorkItem: DispatchWorkItem? = nil public func applicationDidFinishLaunching(_ notification: Notification) { if UserDefaults.standard.object(forKey: "SonioxPlaySounds") == nil { @@ -23,6 +24,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { // Setup Hotkey manager callbacks (Push-To-Talk & Toggle) HotkeyManager.shared.onHotkeyPressed = { [weak self] in guard let self = self else { return } + self.pendingStopWorkItem?.cancel() + self.pendingStopWorkItem = nil if HotkeyManager.shared.currentMode == .pushToTalk { if !self.audioRecorder.isRecording { self.startRecording() @@ -36,7 +39,16 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { guard let self = self else { return } if HotkeyManager.shared.currentMode == .pushToTalk { if self.audioRecorder.isRecording { - self.stopRecording() + self.pendingStopWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + if self.audioRecorder.isRecording { + self.stopRecording() + } + } + self.pendingStopWorkItem = workItem + // Trailing grace period: 0.5s (500ms) buffer so trailing words/syllables are never clipped + DispatchQueue.main.asyncAfter(deadline: .now() + 0.50, execute: workItem) } } } @@ -166,8 +178,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { isBusyFinalizing = true - // Safety timeout: If internet drops or server halts during finalization, guarantee completion within 1.2s - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in + // Safety timeout: If internet drops or server halts during finalization, guarantee completion within 3.5s + DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in guard let self = self, self.isBusyFinalizing else { return } print("AppDelegate: Finalize safety watchdog triggered — recovering transcript.") self.activeSession?.completeWithCurrentText() diff --git a/src/SonioxClient.swift b/src/SonioxClient.swift index e3f115f..ebde563 100644 --- a/src/SonioxClient.swift +++ b/src/SonioxClient.swift @@ -90,6 +90,12 @@ public final class SonioxLiveSession { lock.unlock() print("SonioxLiveSession: Sending finalize packet...") + // 1. Send trailing 120ms silence frame so acoustic model cleanly resolves trailing syllables + let silenceChunk = Data(count: 3840) + let silenceMsg = URLSessionWebSocketTask.Message.data(silenceChunk) + task.send(silenceMsg) { _ in } + + // 2. Send finalize JSON let finalizeMsg = URLSessionWebSocketTask.Message.string("{\"type\": \"finalize\"}") task.send(finalizeMsg) { [weak self] error in if let error = error { @@ -98,8 +104,8 @@ public final class SonioxLiveSession { } } - // Safety timeout fallback: finalize must complete within 800ms - DispatchQueue.global().asyncAfter(deadline: .now() + 0.80) { [weak self] in + // Safety timeout fallback: finalize must complete within 3.0s (accommodates high network TTFB) + DispatchQueue.global().asyncAfter(deadline: .now() + 3.0) { [weak self] in self?.completeWithCurrentText() } } @@ -261,13 +267,19 @@ public final class SonioxLiveSession { var cleaned: [String] = [] let stopWords: Set = ["sex", "from", "no", "oh", "you", "all", "know", "that", "god", "loves", "men", "for", "day", "night", "mankind", "wanted", "moments"] + let preserveWords: Set = [ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", + "tab", "slash", "account", "telegram", "ide", "code", "chatgpt", "claude", + "github", "git", "firefox", "chrome", "safari", "figma", "terminal", + "notes", "music", "finder", "settings", "status", "cancel", "new", "close", "reopen", "search" + ] if faRatio >= 0.25 { for w in words { if matches(enPattern, in: w) && !matches(faPattern, in: w) { let cleanW = w.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".,!?:;،؛؟\"'()[]{}«»-–—")) if stopWords.contains(cleanW) { continue } - if faRatio >= 0.70 { continue } + if faRatio >= 0.70 && !preserveWords.contains(cleanW) { continue } } cleaned.append(w) } diff --git a/src/VoiceCommandExecutor.swift b/src/VoiceCommandExecutor.swift index f48d4b9..5217f68 100644 --- a/src/VoiceCommandExecutor.swift +++ b/src/VoiceCommandExecutor.swift @@ -662,35 +662,38 @@ public final class VoiceCommandExecutor { let t = text.lowercased() let words = t.components(separatedBy: .whitespacesAndNewlines) - // 1. Direct digits (English and Persian) - for i in 1...9 { - let enDig = "\(i)" - let faDigs = ["۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"] - let faDig = faDigs[i - 1] - - for w in words { - let cleanW = w.trimmingCharacters(in: CharacterSet(charactersIn: ".,،؛:!؟?-_")) - if cleanW == enDig || cleanW == faDig { - return i + // 1. Direct digits (English and Persian) anywhere inside words (e.g. "تلگرام ۱", "تلگرام1", "اکانت۱") + for w in words { + let cleanW = w.trimmingCharacters(in: CharacterSet(charactersIn: ".,،؛:!؟?-_/\"'()[]{}«»—–")) + for ch in cleanW { + if let d = Int(String(ch)), d >= 1 && d <= 9 { + return d + } + let faDigits: [Character: Int] = [ + "۱": 1, "۲": 2, "۳": 3, "۴": 4, "۵": 5, "۶": 6, "۷": 7, "۸": 8, "۹": 9, + "١": 1, "٢": 2, "٣": 3, "٤": 4, "٥": 5, "٦": 6, "٧": 7, "٨": 8, "٩": 9 + ] + if let d = faDigits[ch] { + return d } } } - // 2. Word numbers (Persian & English cardinal and ordinal) + // 2. Comprehensive Word numbers (Persian & English cardinal, ordinal, colloquial) let numberWords: [(names: [String], num: Int)] = [ - (["یک", "اول", "نخست", "ی", "one", "first"], 1), - (["دو", "دوم", "د", "two", "second"], 2), - (["سه", "سوم", "س", "three", "third"], 3), - (["چهار", "چهارم", "four", "fourth"], 4), - (["پنج", "پنجم", "five", "fifth"], 5), - (["شش", "ششم", "six", "sixth"], 6), - (["هفت", "هفتم", "seven", "seventh"], 7), - (["هشت", "هشتم", "eight", "eighth"], 8), - (["نه", "نهم", "اخر", "آخر", "nine", "ninth", "last"], 9) + (["یک", "یکی", "اول", "اولی", "اولین", "یکمی", "نخست", "ی", "وان", "one", "first"], 1), + (["دو", "دومی", "دومین", "دوتا", "دوتایی", "د", "تو", "two", "second"], 2), + (["سه", "سومی", "سومین", "سه تا", "سه تایی", "س", "تری", "three", "third"], 3), + (["چهار", "چهارم", "چهارمی", "چهارمین", "چار", "چاری", "چارم", "چارمی", "فور", "four", "fourth"], 4), + (["پنج", "پنجم", "پنجمی", "پنجمین", "پنج تا", "فایو", "five", "fifth"], 5), + (["شش", "ششم", "ششمی", "ششمین", "شیش", "شیشم", "شیشمی", "شیشمین", "سیکس", "six", "sixth"], 6), + (["هفت", "هفتم", "هفتمی", "هفتمین", "سون", "seven", "seventh"], 7), + (["هشت", "هشتم", "هشتمی", "هشتمین", "ایت", "eight", "eighth"], 8), + (["نه", "نهم", "نهمی", "نهمین", "ناین", "اخر", "آخر", "اخری", "آخری", "آخرین", "nine", "ninth", "last"], 9) ] for w in words { - let cleanW = w.trimmingCharacters(in: CharacterSet(charactersIn: ".,،؛:!؟?-_")) + let cleanW = w.trimmingCharacters(in: CharacterSet(charactersIn: ".,،؛:!؟?-_/\"'()[]{}«»—–")) for item in numberWords { if item.names.contains(cleanW) { return item.num @@ -706,7 +709,8 @@ public final class VoiceCommandExecutor { var isTelegram = false var isAntigravity = false - if normalized.contains("تلگرام") || normalized.contains("telegram") { + if normalized.contains("تلگرام") || normalized.contains("telegram") || + normalized.contains("اکانت") || normalized.contains("account") || normalized.contains("حساب") { targetApp = "Telegram" isTelegram = true } else if normalized.contains("فایرفاکس") || normalized.contains("firefox") || normalized.contains("فایر فاکس") || normalized.contains("فایرفکس") { @@ -804,11 +808,17 @@ public final class VoiceCommandExecutor { 6: 22, 7: 26, 8: 28, 9: 25 ] guard let code = keyCodes[number] else { return } - // For Telegram Desktop: Send Cmd+[number] and Cmd+Shift+[number] (configured via shortcuts-custom.json) + // For Telegram Desktop: Send Cmd+[number], Cmd+Shift+[number], and Ctrl combinations (configured via shortcuts-custom.json) pressHotkey(keyCode: code, modifiers: .maskCommand) DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { self.pressHotkey(keyCode: code, modifiers: [.maskCommand, .maskShift]) } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + self.pressHotkey(keyCode: code, modifiers: .maskControl) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { + self.pressHotkey(keyCode: code, modifiers: [.maskControl, .maskShift]) + } } // MARK: - Standalone Tab Commands @@ -1192,7 +1202,7 @@ public final class VoiceCommandExecutor { } if let comp = completion { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { comp() } }