From 837f1e5ec239725f970f364759c2b5847e46ffcb Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Sat, 26 Sep 2026 07:25:32 +0000 Subject: [PATCH] feat: add real-time simultaneous translation support (English, Arabic, German, French, etc.) --- src/AppDelegate.swift | 3 +- src/HUDOverlay.swift | 6 +++- src/SonioxClient.swift | 60 ++++++++++++++++++++++++++++------- src/SonioxSettings.swift | 60 +++++++++++++++++++++++++++++++++++ src/StatusBarController.swift | 25 +++++++++++++++ 5 files changed, 140 insertions(+), 14 deletions(-) diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 312be8d..cffe7b3 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -260,8 +260,9 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { let audioCopy = self.lastRecordedAudio let hints = SonioxSettings.shared.language.hints + let target = SonioxSettings.shared.targetLanguage.isEnabled ? SonioxSettings.shared.targetLanguage.rawValue : nil - SonioxLiveSession.retryFullBufferedAudio(pcmData: audioCopy, languageHints: hints, onProgress: nil) { [weak self] retryResult in + SonioxLiveSession.retryFullBufferedAudio(pcmData: audioCopy, languageHints: hints, targetLanguage: target, onProgress: nil) { [weak self] retryResult in guard let self = self else { return } self.isBusyFinalizing = false diff --git a/src/HUDOverlay.swift b/src/HUDOverlay.swift index eb41008..22e66d3 100644 --- a/src/HUDOverlay.swift +++ b/src/HUDOverlay.swift @@ -214,7 +214,11 @@ final class OutwardExpandingOrbView: NSView { // Update Core Color & Text based on live Ping if let ping = pingQuality { - pingTextLayer.string = ping.labelText + if SonioxSettings.shared.targetLanguage.isEnabled { + pingTextLayer.string = "\(ping.labelText) • \(SonioxSettings.shared.targetLanguage.rawValue.uppercased())" + } else { + pingTextLayer.string = ping.labelText + } switch ping { case .good: // Vibrant Emerald Green (#2DD4BF / #22C55E) diff --git a/src/SonioxClient.swift b/src/SonioxClient.swift index 3596128..84aec50 100644 --- a/src/SonioxClient.swift +++ b/src/SonioxClient.swift @@ -7,6 +7,12 @@ public final class SonioxLiveSession { private static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" private static let origin = "https://translate.compare.soniox.com" + public let targetLanguage: String? + public var isTranslationMode: Bool { + guard let target = targetLanguage, !target.isEmpty, target != "off" else { return false } + return true + } + private var webSocketTask: URLSessionWebSocketTask? private var urlSession: URLSession? private var isFinalizing = false @@ -71,9 +77,19 @@ public final class SonioxLiveSession { } } - public init(languageHints: [String] = ["fa", "en", "ar"], endpoint: String = SonioxLiveSession.primaryWsBase) { + public init( + languageHints: [String] = ["fa", "en", "ar"], + targetLanguage: String? = nil, + endpoint: String = SonioxLiveSession.primaryWsBase + ) { + let effectiveTarget = targetLanguage ?? (SonioxSettings.shared.targetLanguage.isEnabled ? SonioxSettings.shared.targetLanguage.rawValue : nil) + self.targetLanguage = effectiveTarget + let hints = languageHints.map { "language_hints=\($0)" }.joined(separator: "&") - let urlStr = "\(endpoint)?\(hints)&enable_speaker_diarization=false&enable_language_identification=true&enable_endpoint_detection=false&providers=soniox" + var urlStr = "\(endpoint)?\(hints)&enable_speaker_diarization=false&enable_language_identification=true&enable_endpoint_detection=false&providers=soniox" + if let target = effectiveTarget, !target.isEmpty, target != "off" { + urlStr += "&target_language=\(target)" + } guard let url = URL(string: urlStr) else { return } var request = URLRequest(url: url) @@ -221,9 +237,15 @@ public final class SonioxLiveSession { if let type = json["type"] as? String, type == "data", let parts = json["parts"] as? [[String: Any]] { for part in parts { - let transStatus = part["translation_status"] as? String - if transStatus == "translation" { - continue + let isTranslation = (part["translation_status"] as? String) == "translation" + if self.isTranslationMode { + if !isTranslation { + continue + } + } else { + if isTranslation { + continue + } } let pText = part["text"] as? String ?? "" @@ -318,6 +340,7 @@ public final class SonioxLiveSession { public static func retryFullBufferedAudio( pcmData: Data, languageHints: [String], + targetLanguage: String? = nil, onProgress: ((String) -> Void)? = nil, completion: @escaping (Result) -> Void ) { @@ -326,16 +349,17 @@ public final class SonioxLiveSession { return } - print("SonioxLiveSession: Starting Smart Burst Retry with \(pcmData.count) bytes (\(Double(pcmData.count)/32000.0)s audio)...") + let effectiveTarget = targetLanguage ?? (SonioxSettings.shared.targetLanguage.isEnabled ? SonioxSettings.shared.targetLanguage.rawValue : nil) + print("SonioxLiveSession: Starting Smart Burst Retry with \(pcmData.count) bytes (\(Double(pcmData.count)/32000.0)s audio, target: \(effectiveTarget ?? "orig"))...") // Try Primary endpoint, failover to Fallback if needed - executeBurstSession(endpoint: primaryWsBase, pcmData: pcmData, languageHints: languageHints, onProgress: onProgress) { result in + executeBurstSession(endpoint: primaryWsBase, pcmData: pcmData, languageHints: languageHints, targetLanguage: effectiveTarget, onProgress: onProgress) { result in switch result { case .success(let text): completion(.success(text)) case .failure(let error): print("SonioxLiveSession: Primary retry failed (\(error)), attempting fallback endpoint...") - executeBurstSession(endpoint: fallbackWsBase, pcmData: pcmData, languageHints: languageHints, onProgress: onProgress, completion: completion) + executeBurstSession(endpoint: fallbackWsBase, pcmData: pcmData, languageHints: languageHints, targetLanguage: effectiveTarget, onProgress: onProgress, completion: completion) } } } @@ -344,11 +368,16 @@ public final class SonioxLiveSession { endpoint: String, pcmData: Data, languageHints: [String], + targetLanguage: String?, onProgress: ((String) -> Void)?, completion: @escaping (Result) -> Void ) { + let isTranslationMode = targetLanguage != nil && !targetLanguage!.isEmpty && targetLanguage != "off" let hints = languageHints.map { "language_hints=\($0)" }.joined(separator: "&") - let urlStr = "\(endpoint)?\(hints)&enable_speaker_diarization=false&enable_language_identification=true&enable_endpoint_detection=false&providers=soniox" + var urlStr = "\(endpoint)?\(hints)&enable_speaker_diarization=false&enable_language_identification=true&enable_endpoint_detection=false&providers=soniox" + if let target = targetLanguage, !target.isEmpty, target != "off" { + urlStr += "&target_language=\(target)" + } guard let url = URL(string: urlStr) else { completion(.failure(NSError(domain: "Soniox", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]))) return @@ -404,7 +433,12 @@ public final class SonioxLiveSession { var gotFin = false if let parts = json["parts"] as? [[String: Any]] { for p in parts { - if (p["translation_status"] as? String) == "translation" { continue } + let isTranslation = (p["translation_status"] as? String) == "translation" + if isTranslationMode { + if !isTranslation { continue } + } else { + if isTranslation { continue } + } let t = p["text"] as? String ?? "" let isFinal = p["is_final"] as? Bool ?? false if t.contains("") { @@ -584,7 +618,8 @@ public final class SonioxSessionPool { DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self = self else { return } let hints = SonioxSettings.shared.language.hints - let session = SonioxLiveSession(languageHints: hints) + let target = SonioxSettings.shared.targetLanguage.isEnabled ? SonioxSettings.shared.targetLanguage.rawValue : nil + let session = SonioxLiveSession(languageHints: hints, targetLanguage: target) // Allow 400ms for connection & initial ping DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in @@ -629,6 +664,7 @@ public final class SonioxSessionPool { print("SonioxSessionPool: Prewarmed session not ready/stale (age: \(String(format: "%.1f", age))s), creating immediate on-demand session.") let hints = SonioxSettings.shared.language.hints - return SonioxLiveSession(languageHints: hints) + let target = SonioxSettings.shared.targetLanguage.isEnabled ? SonioxSettings.shared.targetLanguage.rawValue : nil + return SonioxLiveSession(languageHints: hints, targetLanguage: target) } } diff --git a/src/SonioxSettings.swift b/src/SonioxSettings.swift index f558d0e..9445b43 100644 --- a/src/SonioxSettings.swift +++ b/src/SonioxSettings.swift @@ -34,6 +34,54 @@ public enum RecognitionLanguage: String, CaseIterable { } } +public enum TranslationTargetLanguage: String, CaseIterable { + case off = "off" + case english = "en" + case arabic = "ar" + case german = "de" + case french = "fr" + case spanish = "es" + case turkish = "tr" + case russian = "ru" + case chinese = "zh" + case japanese = "ja" + case italian = "it" + case persian = "fa" + + public var displayName: String { + switch self { + case .off: + return "Off (Original Spoken Language)" + case .english: + return "🇺🇸 English (انگلیسی)" + case .arabic: + return "🇸🇦 Arabic (عربی)" + case .german: + return "🇩🇪 German (آلمانی)" + case .french: + return "🇫🇷 French (فرانسوی)" + case .spanish: + return "🇪🇸 Spanish (اسپانیایی)" + case .turkish: + return "🇹🇷 Turkish (ترکی استانبولی)" + case .russian: + return "🇷🇺 Russian (روسی)" + case .chinese: + return "🇨🇳 Chinese (چینی)" + case .japanese: + return "🇯🇵 Japanese (ژاپنی)" + case .italian: + return "🇮🇹 Italian (ایتالیایی)" + case .persian: + return "🇮🇷 Persian (فارسی)" + } + } + + public var isEnabled: Bool { + return self != .off + } +} + public enum NumberFormatOption: String, CaseIterable { case digits = "digits" case persianDigits = "persianDigits" @@ -74,6 +122,17 @@ public final class SonioxSettings { } } + public var targetLanguage: TranslationTargetLanguage { + get { + let val = defaults.string(forKey: "SonioxTargetLanguage") ?? TranslationTargetLanguage.off.rawValue + return TranslationTargetLanguage(rawValue: val) ?? .off + } + set { + defaults.set(newValue.rawValue, forKey: "SonioxTargetLanguage") + SonioxSessionPool.shared.reconnectAll() + } + } + public var numberFormat: NumberFormatOption { get { let val = defaults.string(forKey: "SonioxNumberFormat") ?? NumberFormatOption.digits.rawValue @@ -171,6 +230,7 @@ public final class SonioxSettings { defaults.removeObject(forKey: "SonioxHotkeyPreset") defaults.removeObject(forKey: "SonioxDictationMode") defaults.removeObject(forKey: "SonioxLanguage") + defaults.removeObject(forKey: "SonioxTargetLanguage") defaults.removeObject(forKey: "SonioxNumberFormat") defaults.removeObject(forKey: "SonioxVoicePunctuation") defaults.removeObject(forKey: "SonioxDomainVocabulary") diff --git a/src/StatusBarController.swift b/src/StatusBarController.swift index 4d5326e..a1816b2 100644 --- a/src/StatusBarController.swift +++ b/src/StatusBarController.swift @@ -114,6 +114,24 @@ public final class StatusBarController: NSObject, NSMenuDelegate { langMenuItem.submenu = langMenu menu.addItem(langMenuItem) + // 5. Real-Time Translation Submenu + let transMenu = NSMenu() + for target in TranslationTargetLanguage.allCases { + let item = NSMenuItem(title: target.displayName, action: #selector(selectTranslationTargetAction(_:)), keyEquivalent: "") + item.target = self + item.representedObject = target + item.state = (SonioxSettings.shared.targetLanguage == target) ? .on : .off + transMenu.addItem(item) + if target == .off { + transMenu.addItem(NSMenuItem.separator()) + } + } + let currentTarget = SonioxSettings.shared.targetLanguage + let transTitle = currentTarget.isEnabled ? "🔄 Live Translation (\(currentTarget.displayName))" : "🔄 Live Translation (Off)" + let transMenuItem = NSMenuItem(title: transTitle, action: nil, keyEquivalent: "") + transMenuItem.submenu = transMenu + menu.addItem(transMenuItem) + // 5. Microphone Input Device Submenu let micDeviceMenu = NSMenu() let availableDevices = AudioRecorder.availableAudioDevices() @@ -342,6 +360,13 @@ public final class StatusBarController: NSObject, NSMenuDelegate { } } + @objc private func selectTranslationTargetAction(_ sender: NSMenuItem) { + if let target = sender.representedObject as? TranslationTargetLanguage { + SonioxSettings.shared.targetLanguage = target + buildMenu() + } + } + @objc private func selectAudioDeviceAction(_ sender: NSMenuItem) { let deviceID = sender.representedObject as? String SonioxSettings.shared.preferredAudioDeviceID = deviceID