Browse Source

feat: add real-time simultaneous translation support (English, Arabic, German, French, etc.)

main
Ali Alavi 2 weeks ago
parent
commit
837f1e5ec2
  1. 3
      src/AppDelegate.swift
  2. 4
      src/HUDOverlay.swift
  3. 58
      src/SonioxClient.swift
  4. 60
      src/SonioxSettings.swift
  5. 25
      src/StatusBarController.swift

3
src/AppDelegate.swift

@ -260,8 +260,9 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
let audioCopy = self.lastRecordedAudio let audioCopy = self.lastRecordedAudio
let hints = SonioxSettings.shared.language.hints 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 } guard let self = self else { return }
self.isBusyFinalizing = false self.isBusyFinalizing = false

4
src/HUDOverlay.swift

@ -214,7 +214,11 @@ final class OutwardExpandingOrbView: NSView {
// Update Core Color & Text based on live Ping // Update Core Color & Text based on live Ping
if let ping = pingQuality { if let ping = pingQuality {
if SonioxSettings.shared.targetLanguage.isEnabled {
pingTextLayer.string = "\(ping.labelText) • \(SonioxSettings.shared.targetLanguage.rawValue.uppercased())"
} else {
pingTextLayer.string = ping.labelText pingTextLayer.string = ping.labelText
}
switch ping { switch ping {
case .good: case .good:
// Vibrant Emerald Green (#2DD4BF / #22C55E) // Vibrant Emerald Green (#2DD4BF / #22C55E)

58
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 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" 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 webSocketTask: URLSessionWebSocketTask?
private var urlSession: URLSession? private var urlSession: URLSession?
private var isFinalizing = false 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 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 } guard let url = URL(string: urlStr) else { return }
var request = URLRequest(url: url) var request = URLRequest(url: url)
@ -221,10 +237,16 @@ public final class SonioxLiveSession {
if let type = json["type"] as? String, type == "data", if let type = json["type"] as? String, type == "data",
let parts = json["parts"] as? [[String: Any]] { let parts = json["parts"] as? [[String: Any]] {
for part in parts { for part in parts {
let transStatus = part["translation_status"] as? String
if transStatus == "translation" {
let isTranslation = (part["translation_status"] as? String) == "translation"
if self.isTranslationMode {
if !isTranslation {
continue
}
} else {
if isTranslation {
continue continue
} }
}
let pText = part["text"] as? String ?? "" let pText = part["text"] as? String ?? ""
let isFinal = part["is_final"] as? Bool ?? false let isFinal = part["is_final"] as? Bool ?? false
@ -318,6 +340,7 @@ public final class SonioxLiveSession {
public static func retryFullBufferedAudio( public static func retryFullBufferedAudio(
pcmData: Data, pcmData: Data,
languageHints: [String], languageHints: [String],
targetLanguage: String? = nil,
onProgress: ((String) -> Void)? = nil, onProgress: ((String) -> Void)? = nil,
completion: @escaping (Result<String, Error>) -> Void completion: @escaping (Result<String, Error>) -> Void
) { ) {
@ -326,16 +349,17 @@ public final class SonioxLiveSession {
return 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 // 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 { switch result {
case .success(let text): case .success(let text):
completion(.success(text)) completion(.success(text))
case .failure(let error): case .failure(let error):
print("SonioxLiveSession: Primary retry failed (\(error)), attempting fallback endpoint...") 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, endpoint: String,
pcmData: Data, pcmData: Data,
languageHints: [String], languageHints: [String],
targetLanguage: String?,
onProgress: ((String) -> Void)?, onProgress: ((String) -> Void)?,
completion: @escaping (Result<String, Error>) -> Void completion: @escaping (Result<String, Error>) -> Void
) { ) {
let isTranslationMode = targetLanguage != nil && !targetLanguage!.isEmpty && targetLanguage != "off"
let hints = languageHints.map { "language_hints=\($0)" }.joined(separator: "&") 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 { guard let url = URL(string: urlStr) else {
completion(.failure(NSError(domain: "Soniox", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]))) completion(.failure(NSError(domain: "Soniox", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])))
return return
@ -404,7 +433,12 @@ public final class SonioxLiveSession {
var gotFin = false var gotFin = false
if let parts = json["parts"] as? [[String: Any]] { if let parts = json["parts"] as? [[String: Any]] {
for p in parts { 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 t = p["text"] as? String ?? ""
let isFinal = p["is_final"] as? Bool ?? false let isFinal = p["is_final"] as? Bool ?? false
if t.contains("<fin>") { if t.contains("<fin>") {
@ -584,7 +618,8 @@ public final class SonioxSessionPool {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
let hints = SonioxSettings.shared.language.hints 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 // Allow 400ms for connection & initial ping
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in 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.") print("SonioxSessionPool: Prewarmed session not ready/stale (age: \(String(format: "%.1f", age))s), creating immediate on-demand session.")
let hints = SonioxSettings.shared.language.hints 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)
} }
} }

60
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 { public enum NumberFormatOption: String, CaseIterable {
case digits = "digits" case digits = "digits"
case persianDigits = "persianDigits" 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 { public var numberFormat: NumberFormatOption {
get { get {
let val = defaults.string(forKey: "SonioxNumberFormat") ?? NumberFormatOption.digits.rawValue let val = defaults.string(forKey: "SonioxNumberFormat") ?? NumberFormatOption.digits.rawValue
@ -171,6 +230,7 @@ public final class SonioxSettings {
defaults.removeObject(forKey: "SonioxHotkeyPreset") defaults.removeObject(forKey: "SonioxHotkeyPreset")
defaults.removeObject(forKey: "SonioxDictationMode") defaults.removeObject(forKey: "SonioxDictationMode")
defaults.removeObject(forKey: "SonioxLanguage") defaults.removeObject(forKey: "SonioxLanguage")
defaults.removeObject(forKey: "SonioxTargetLanguage")
defaults.removeObject(forKey: "SonioxNumberFormat") defaults.removeObject(forKey: "SonioxNumberFormat")
defaults.removeObject(forKey: "SonioxVoicePunctuation") defaults.removeObject(forKey: "SonioxVoicePunctuation")
defaults.removeObject(forKey: "SonioxDomainVocabulary") defaults.removeObject(forKey: "SonioxDomainVocabulary")

25
src/StatusBarController.swift

@ -114,6 +114,24 @@ public final class StatusBarController: NSObject, NSMenuDelegate {
langMenuItem.submenu = langMenu langMenuItem.submenu = langMenu
menu.addItem(langMenuItem) 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 // 5. Microphone Input Device Submenu
let micDeviceMenu = NSMenu() let micDeviceMenu = NSMenu()
let availableDevices = AudioRecorder.availableAudioDevices() 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) { @objc private func selectAudioDeviceAction(_ sender: NSMenuItem) {
let deviceID = sender.representedObject as? String let deviceID = sender.representedObject as? String
SonioxSettings.shared.preferredAudioDeviceID = deviceID SonioxSettings.shared.preferredAudioDeviceID = deviceID

Loading…
Cancel
Save