Browse Source

fix(mac3): preserve local trailing grace period, silence frames, and digit expansions

main
Ali Alavi 2 weeks ago
parent
commit
90ba00f361
  1. 0
      package.sh
  2. 16
      src/AppDelegate.swift
  3. 18
      src/SonioxClient.swift
  4. 56
      src/VoiceCommandExecutor.swift

0
package.sh

16
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()
@ -35,10 +38,19 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
HotkeyManager.shared.onHotkeyReleased = { [weak self] in
guard let self = self else { return }
if HotkeyManager.shared.currentMode == .pushToTalk {
if self.audioRecorder.isRecording {
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)
}
}
}
HotkeyManager.shared.registerHotkeys()
@ -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()

18
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<String> = ["sex", "from", "no", "oh", "you", "all", "know", "that", "god", "loves", "men", "for", "day", "night", "mankind", "wanted", "moments"]
let preserveWords: Set<String> = [
"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)
}

56
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]
// 1. Direct digits (English and Persian) anywhere inside words (e.g. "تلگرام ۱", "تلگرام1", "اکانت۱")
for w in words {
let cleanW = w.trimmingCharacters(in: CharacterSet(charactersIn: ".,،؛:!؟?-_"))
if cleanW == enDig || cleanW == faDig {
return i
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()
}
}

Loading…
Cancel
Save