Browse Source

feat(core): comprehensive resilience, performance & instant Escape cancel (v2.07)

main
Ali Alavi 1 week ago
parent
commit
14f155732e
  1. 2
      Info.plist
  2. 2
      package.sh
  3. 858
      src/AppDelegate.swift
  4. 109
      src/AudioRecorder.swift
  5. 40
      src/HUDOverlay.swift
  6. 93
      src/HotkeyManager.swift
  7. 103
      src/InputBoxManager.swift
  8. 109
      src/PingMonitor.swift
  9. 457
      src/SonioxClient.swift
  10. 123
      src/TextProcessor.swift
  11. 13
      src/VoiceCommandLogger.swift

2
Info.plist

@ -19,7 +19,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2.06</string>
<string>2.07</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>1</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>

2
package.sh

@ -13,7 +13,7 @@ rm -rf "$APP_BUNDLE"
# 1. Compile Swift sources (Pure Native Swift) # 1. Compile Swift sources (Pure Native Swift)
swiftc -O -target arm64-apple-macosx13.0 \ swiftc -O -target arm64-apple-macosx13.0 \
-framework Cocoa -framework AVFoundation -framework Carbon -framework AppIntents \
-framework Cocoa -framework AVFoundation -framework Carbon -framework AppIntents -framework Network \
"$PROJECT_DIR"/src/*.swift \ "$PROJECT_DIR"/src/*.swift \
-o "$BUILD_DIR/SonioxVoice" -o "$BUILD_DIR/SonioxVoice"

858
src/AppDelegate.swift
File diff suppressed because it is too large
View File

109
src/AudioRecorder.swift

@ -12,6 +12,11 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer
private var pcmBuffer = Data() private var pcmBuffer = Data()
private let lock = NSLock() private let lock = NSLock()
private let captureQueue = DispatchQueue(label: "com.soniox.audiocapture", qos: .userInteractive) private let captureQueue = DispatchQueue(label: "com.soniox.audiocapture", qos: .userInteractive)
private let sessionQueue = DispatchQueue(label: "com.soniox.audiosession", qos: .userInitiated)
// Pre-allocated reusable buffers to eliminate 50Hz per-frame heap allocations
private var reusableSrcBuffer: AVAudioPCMBuffer?
private var reusableOutBuffer: AVAudioPCMBuffer?
public private(set) var isRecording = false public private(set) var isRecording = false
public var onAudioLevelUpdate: ((Float) -> Void)? public var onAudioLevelUpdate: ((Float) -> Void)?
@ -50,16 +55,22 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer
public func resetSession() { public func resetSession() {
lock.lock() lock.lock()
defer { lock.unlock() }
isRecording = false isRecording = false
if let session = captureSession {
if session.isRunning {
session.stopRunning()
}
}
let session = captureSession
captureSession = nil captureSession = nil
audioOutput = nil audioOutput = nil
audioConverter = nil audioConverter = nil
reusableSrcBuffer = nil
reusableOutBuffer = nil
lock.unlock()
if let session = session {
sessionQueue.async {
if session.isRunning {
session.stopRunning()
}
}
}
print("AudioRecorder: Hardware capture session reset.") print("AudioRecorder: Hardware capture session reset.")
} }
@ -124,27 +135,39 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer
public func startRecording() throws { public func startRecording() throws {
lock.lock() lock.lock()
defer { lock.unlock() }
if isRecording { return }
pcmBuffer.removeAll()
try ensureSessionConfigured()
if let session = captureSession, !session.isRunning {
session.startRunning()
if isRecording {
lock.unlock()
return
} }
pcmBuffer.removeAll(keepingCapacity: true)
try ensureSessionConfigured()
let session = captureSession
isRecording = true isRecording = true
lock.unlock()
if let session = session {
sessionQueue.async {
if !session.isRunning {
session.startRunning()
}
}
}
print("AudioRecorder: Fast recording started (mic hardware ON).") print("AudioRecorder: Fast recording started (mic hardware ON).")
} }
public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
guard isRecording else { return }
lock.lock()
let active = isRecording
lock.unlock()
guard active else { return }
guard let formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer) else { return } guard let formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer) else { return }
let srcFormat = AVAudioFormat(cmAudioFormatDescription: formatDesc) let srcFormat = AVAudioFormat(cmAudioFormatDescription: formatDesc)
if audioConverter == nil || audioConverter?.inputFormat != srcFormat { if audioConverter == nil || audioConverter?.inputFormat != srcFormat {
audioConverter = AVAudioConverter(from: srcFormat, to: targetFormat) audioConverter = AVAudioConverter(from: srcFormat, to: targetFormat)
reusableSrcBuffer = nil
reusableOutBuffer = nil
} }
guard let converter = self.audioConverter else { return } guard let converter = self.audioConverter else { return }
@ -152,8 +175,12 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer
let numSamples = CMSampleBufferGetNumSamples(sampleBuffer) let numSamples = CMSampleBufferGetNumSamples(sampleBuffer)
guard numSamples > 0 else { return } guard numSamples > 0 else { return }
guard let srcBuffer = AVAudioPCMBuffer(pcmFormat: srcFormat, frameCapacity: AVAudioFrameCount(numSamples)) else { return }
srcBuffer.frameLength = AVAudioFrameCount(numSamples)
let requiredSrcCap = AVAudioFrameCount(numSamples)
if reusableSrcBuffer == nil || reusableSrcBuffer!.frameCapacity < requiredSrcCap || reusableSrcBuffer!.format != srcFormat {
reusableSrcBuffer = AVAudioPCMBuffer(pcmFormat: srcFormat, frameCapacity: max(requiredSrcCap, 4096))
}
guard let srcBuffer = reusableSrcBuffer else { return }
srcBuffer.frameLength = requiredSrcCap
var lengthAtOffset = 0 var lengthAtOffset = 0
var totalLength = 0 var totalLength = 0
@ -169,7 +196,10 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer
} }
let outCapacity = AVAudioFrameCount(Double(numSamples) * (16000.0 / srcFormat.sampleRate)) + 128 let outCapacity = AVAudioFrameCount(Double(numSamples) * (16000.0 / srcFormat.sampleRate)) + 128
guard let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outCapacity) else { return }
if reusableOutBuffer == nil || reusableOutBuffer!.frameCapacity < outCapacity {
reusableOutBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: max(outCapacity, 2048))
}
guard let outBuffer = reusableOutBuffer else { return }
var error: NSError? var error: NSError?
var haveData = true var haveData = true
@ -191,6 +221,10 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer
let data = Data(bytes: int16Ptr, count: bytesCount) let data = Data(bytes: int16Ptr, count: bytesCount)
lock.lock() lock.lock()
guard isRecording else {
lock.unlock()
return
}
pcmBuffer.append(data) pcmBuffer.append(data)
lock.unlock() lock.unlock()
@ -215,6 +249,7 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer
} }
} }
/// Non-blocking drain of the serial capture queue + async hardware shutdown
public func stopRecording() -> Data { public func stopRecording() -> Data {
lock.lock() lock.lock()
if !isRecording { if !isRecording {
@ -224,16 +259,46 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer
} }
lock.unlock() lock.unlock()
// Allow in-flight audio sample buffers on captureQueue to finish processing
usleep(60000)
// Synchronize with serial captureQueue (<1ms) so any in-flight sampleBuffer finishes cleanly without 60ms usleep!
captureQueue.sync {
self.lock.lock()
self.isRecording = false
self.lock.unlock()
}
lock.lock() lock.lock()
isRecording = false
captureSession?.stopRunning()
let data = pcmBuffer let data = pcmBuffer
let session = captureSession
lock.unlock() lock.unlock()
// Stop hardware capture asynchronously on background queue so main thread & WebSocket finalize have 0ms blocking
if let session = session {
sessionQueue.async {
if session.isRunning {
session.stopRunning()
}
}
}
print("AudioRecorder: Stopped. Microphone hardware OFF. Total PCM captured: \(data.count) bytes.") print("AudioRecorder: Stopped. Microphone hardware OFF. Total PCM captured: \(data.count) bytes.")
return data return data
} }
/// Immediate abort (e.g. Escape key pressed) — discards buffered audio immediately
public func abortRecording() {
lock.lock()
isRecording = false
pcmBuffer.removeAll(keepingCapacity: true)
let session = captureSession
lock.unlock()
if let session = session {
sessionQueue.async {
if session.isRunning {
session.stopRunning()
}
}
}
print("AudioRecorder: Aborted immediately.")
}
} }

40
src/HUDOverlay.swift

@ -15,13 +15,15 @@ public final class HUDOverlayController {
private var window: NSPanel? private var window: NSPanel?
private var orbView: OutwardExpandingOrbView? private var orbView: OutwardExpandingOrbView?
private var isVisible = false
public private(set) var isVisible = false
private var autoHideWorkItem: DispatchWorkItem?
// Large transparent canvas so outward expansion and shadows never clip // Large transparent canvas so outward expansion and shadows never clip
private let canvasSize: CGFloat = 200 private let canvasSize: CGFloat = 200
private init() { private init() {
DispatchQueue.main.async {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.setupWindow() self.setupWindow()
NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main) { [weak self] _ in NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main) { [weak self] _ in
self?.reposition() self?.reposition()
@ -68,6 +70,9 @@ public final class HUDOverlayController {
public func show(state: HUDState) { public func show(state: HUDState) {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.autoHideWorkItem?.cancel()
self.autoHideWorkItem = nil
if self.window == nil { if self.window == nil {
self.setupWindow() self.setupWindow()
} }
@ -76,7 +81,7 @@ public final class HUDOverlayController {
switch state { switch state {
case .hidden: case .hidden:
self.hide()
self.hideImmediately()
case .recording(let level, let isLagging, let pingQuality): case .recording(let level, let isLagging, let pingQuality):
if !self.isVisible || window.alphaValue < 0.95 { if !self.isVisible || window.alphaValue < 0.95 {
@ -104,9 +109,11 @@ public final class HUDOverlayController {
case .success: case .success:
orb.showSuccessState() orb.showSuccessState()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
self.hide()
let work = DispatchWorkItem { [weak self] in
self?.hide()
} }
self.autoHideWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: work)
case .error(let message): case .error(let message):
if !self.isVisible || window.alphaValue < 0.95 { if !self.isVisible || window.alphaValue < 0.95 {
@ -115,16 +122,35 @@ public final class HUDOverlayController {
self.isVisible = true self.isVisible = true
} }
orb.showErrorState(message: message) orb.showErrorState(message: message)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.40) {
self.hide()
let work = DispatchWorkItem { [weak self] in
self?.hide()
} }
self.autoHideWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1.40, execute: work)
}
}
}
/// Instant 0ms dismissal (used when Escape is pressed or short key tap is cancelled)
public func hideImmediately() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.autoHideWorkItem?.cancel()
self.autoHideWorkItem = nil
self.isVisible = false
if let window = self.window {
window.alphaValue = 0.0
window.orderOut(nil)
} }
self.orbView?.reset()
} }
} }
public func hide() { public func hide() {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self, let window = self.window, self.isVisible else { return } guard let self = self, let window = self.window, self.isVisible else { return }
self.autoHideWorkItem?.cancel()
self.autoHideWorkItem = nil
self.isVisible = false self.isVisible = false
NSAnimationContext.runAnimationGroup({ context in NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.15 context.duration = 0.15

93
src/HotkeyManager.swift

@ -63,12 +63,15 @@ public final class HotkeyManager {
public var onHotkeyPressed: (() -> Void)? public var onHotkeyPressed: (() -> Void)?
public var onHotkeyReleased: (() -> Void)? public var onHotkeyReleased: (() -> Void)?
public var onEscapePressed: (() -> Bool)? // Returns true if Soniox was active and consumed Escape
private let stateLock = NSLock()
private var hotKeyRef: EventHotKeyRef? private var hotKeyRef: EventHotKeyRef?
private var eventHandlerRef: EventHandlerRef? private var eventHandlerRef: EventHandlerRef?
private var eventTapPort: CFMachPort? private var eventTapPort: CFMachPort?
private var runLoopSource: CFRunLoopSource? private var runLoopSource: CFRunLoopSource?
private var globalMonitor: Any? private var globalMonitor: Any?
private var localMonitor: Any?
private var isOptionPhysicallyDown = false private var isOptionPhysicallyDown = false
private var isCapsLockPhysicallyDown = false private var isCapsLockPhysicallyDown = false
@ -76,6 +79,8 @@ public final class HotkeyManager {
private var isFnPhysicallyDown = false private var isFnPhysicallyDown = false
public var isPhysicalKeyDown: Bool { public var isPhysicalKeyDown: Bool {
stateLock.lock()
defer { stateLock.unlock() }
return isOptionPhysicallyDown || isCapsLockPhysicallyDown || isF5PhysicallyDown || isFnPhysicallyDown return isOptionPhysicallyDown || isCapsLockPhysicallyDown || isF5PhysicallyDown || isFnPhysicallyDown
} }
private var lastFnPressTime: TimeInterval = 0 private var lastFnPressTime: TimeInterval = 0
@ -136,10 +141,12 @@ public final class HotkeyManager {
} }
public func resetKeyState() { public func resetKeyState() {
stateLock.lock()
isOptionPhysicallyDown = false isOptionPhysicallyDown = false
isF5PhysicallyDown = false isF5PhysicallyDown = false
isCapsLockPhysicallyDown = false isCapsLockPhysicallyDown = false
isFnPhysicallyDown = false isFnPhysicallyDown = false
stateLock.unlock()
} }
public func registerHotkeys() { public func registerHotkeys() {
@ -187,17 +194,23 @@ public final class HotkeyManager {
) )
} }
// 2. Global Event Tap for high-priority interception (Microphone Key, Option, CapsLock, Fn/Globe)
// 2. Global Event Tap for high-priority interception (Microphone Key, Option, CapsLock, Fn/Globe, Escape)
setupEventTap() setupEventTap()
// 3. Dual-layer fallback NSEvent monitor (catches flagsChanged/Option even after wake/sleep)
// 3. Fallback NSEvent monitor (active ONLY if CGEventTap is disabled/unavailable)
setupGlobalMonitor() setupGlobalMonitor()
} }
private func triggerAction() { private func triggerAction() {
stateLock.lock()
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
guard (now - lastTriggerTime) > 0.10 else { return }
guard (now - lastTriggerTime) > 0.10 else {
stateLock.unlock()
return
}
lastTriggerTime = now lastTriggerTime = now
stateLock.unlock()
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.onHotkeyPressed?() self?.onHotkeyPressed?()
} }
@ -223,17 +236,29 @@ public final class HotkeyManager {
let flags = event.flags.rawValue let flags = event.flags.rawValue
let keyCode = event.getIntegerValueField(.keyboardEventKeycode) let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
// 0. Escape Key (kVK_Escape = 53): Instant Cancel of active recording / transcription / recovery / HUD
if keyCode == 53 && type == .keyDown {
if let consumed = manager.onEscapePressed?(), consumed {
return nil // Swallow Escape so it only cancels Soniox and doesn't close user's dialog/page
}
}
// 1. Microphone Key (🎙️ / F5) // 1. Microphone Key (🎙️ / F5)
if manager.currentPreset == .micKey { if manager.currentPreset == .micKey {
if keyCode == 96 { // 96 = kVK_F5 if keyCode == 96 { // 96 = kVK_F5
if type == .keyDown { if type == .keyDown {
if !manager.isF5PhysicallyDown {
manager.isF5PhysicallyDown = true
manager.stateLock.lock()
let wasDown = manager.isF5PhysicallyDown
manager.isF5PhysicallyDown = true
manager.stateLock.unlock()
if !wasDown {
manager.triggerAction() manager.triggerAction()
} }
return nil // Swallow F5 so it doesn't leak to apps return nil // Swallow F5 so it doesn't leak to apps
} else if type == .keyUp { } else if type == .keyUp {
manager.stateLock.lock()
manager.isF5PhysicallyDown = false manager.isF5PhysicallyDown = false
manager.stateLock.unlock()
if manager.currentMode == .pushToTalk { if manager.currentMode == .pushToTalk {
DispatchQueue.main.async { DispatchQueue.main.async {
manager.onHotkeyReleased?() manager.onHotkeyReleased?()
@ -248,8 +273,14 @@ public final class HotkeyManager {
else if manager.currentPreset == .option { else if manager.currentPreset == .option {
if keyCode == 58 || keyCode == 61 { // 58 = Left Option, 61 = Right Option if keyCode == 58 || keyCode == 61 { // 58 = Left Option, 61 = Right Option
let isAlt = (flags & CGEventFlags.maskAlternate.rawValue) != 0 let isAlt = (flags & CGEventFlags.maskAlternate.rawValue) != 0
if isAlt != manager.isOptionPhysicallyDown {
manager.stateLock.lock()
let changed = (isAlt != manager.isOptionPhysicallyDown)
if changed {
manager.isOptionPhysicallyDown = isAlt manager.isOptionPhysicallyDown = isAlt
}
manager.stateLock.unlock()
if changed {
if isAlt { if isAlt {
manager.triggerAction() manager.triggerAction()
} else if manager.currentMode == .pushToTalk { } else if manager.currentMode == .pushToTalk {
@ -264,11 +295,14 @@ public final class HotkeyManager {
// 3. CapsLock Key // 3. CapsLock Key
else if manager.currentPreset == .capsLock { else if manager.currentPreset == .capsLock {
if keyCode == 57 { // 57 = CapsLock if keyCode == 57 { // 57 = CapsLock
if !manager.isCapsLockPhysicallyDown {
manager.isCapsLockPhysicallyDown = true
manager.stateLock.lock()
let nowDown = !manager.isCapsLockPhysicallyDown
manager.isCapsLockPhysicallyDown = nowDown
manager.stateLock.unlock()
if nowDown {
manager.triggerAction() manager.triggerAction()
} else { } else {
manager.isCapsLockPhysicallyDown = false
if manager.currentMode == .pushToTalk { if manager.currentMode == .pushToTalk {
DispatchQueue.main.async { DispatchQueue.main.async {
manager.onHotkeyReleased?() manager.onHotkeyReleased?()
@ -294,9 +328,14 @@ public final class HotkeyManager {
else if manager.currentPreset == .doubleTapFn || manager.currentPreset == .holdFn { else if manager.currentPreset == .doubleTapFn || manager.currentPreset == .holdFn {
if keyCode == 63 { // 63 = kVK_Function (Fn / Globe) if keyCode == 63 { // 63 = kVK_Function (Fn / Globe)
let isFnActive = (flags & CGEventFlags.maskSecondaryFn.rawValue) != 0 let isFnActive = (flags & CGEventFlags.maskSecondaryFn.rawValue) != 0
if isFnActive != manager.isFnPhysicallyDown {
manager.stateLock.lock()
let changed = (isFnActive != manager.isFnPhysicallyDown)
if changed {
manager.isFnPhysicallyDown = isFnActive manager.isFnPhysicallyDown = isFnActive
}
manager.stateLock.unlock()
if changed {
if isFnActive { if isFnActive {
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
let interval = now - manager.lastFnPressTime let interval = now - manager.lastFnPressTime
@ -350,18 +389,36 @@ public final class HotkeyManager {
self.globalMonitor = nil self.globalMonitor = nil
} }
// Active backup monitor that never dies on sleep/wake
// Active backup monitor: ONLY acts if CGEventTap is nil or disabled by OS, eliminating dual-trigger race conditions!
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged, .keyDown, .keyUp]) { [weak self] event in globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged, .keyDown, .keyUp]) { [weak self] event in
guard let self = self else { return } guard let self = self else { return }
if let tap = self.eventTapPort, CGEvent.tapIsEnabled(tap: tap) {
return // Primary CGEventTap is healthy and handling events; do not double-process
}
// Wake up eventTap if it was disabled
if let tap = self.eventTapPort {
CGEvent.tapEnable(tap: tap, enable: true)
}
let flags = event.modifierFlags let flags = event.modifierFlags
let keyCode = event.keyCode let keyCode = event.keyCode
if keyCode == 53 && event.type == .keyDown {
_ = self.onEscapePressed?()
return
}
if self.currentPreset == .option { if self.currentPreset == .option {
if keyCode == 58 || keyCode == 61 { // Left or Right Option
if keyCode == 58 || keyCode == 61 {
let isAlt = flags.contains(.option) let isAlt = flags.contains(.option)
if isAlt != self.isOptionPhysicallyDown {
self.isOptionPhysicallyDown = isAlt
self.stateLock.lock()
let changed = (isAlt != self.isOptionPhysicallyDown)
if changed { self.isOptionPhysicallyDown = isAlt }
self.stateLock.unlock()
if changed {
if isAlt { if isAlt {
self.triggerAction() self.triggerAction()
} else if self.currentMode == .pushToTalk { } else if self.currentMode == .pushToTalk {
@ -378,8 +435,12 @@ public final class HotkeyManager {
} else if self.currentPreset == .capsLock { } else if self.currentPreset == .capsLock {
if keyCode == 57 { if keyCode == 57 {
let isCaps = flags.contains(.capsLock) let isCaps = flags.contains(.capsLock)
if isCaps != self.isCapsLockPhysicallyDown {
self.isCapsLockPhysicallyDown = isCaps
self.stateLock.lock()
let changed = (isCaps != self.isCapsLockPhysicallyDown)
if changed { self.isCapsLockPhysicallyDown = isCaps }
self.stateLock.unlock()
if changed {
if isCaps { if isCaps {
self.triggerAction() self.triggerAction()
} else if self.currentMode == .pushToTalk { } else if self.currentMode == .pushToTalk {

103
src/InputBoxManager.swift

@ -9,17 +9,31 @@ public final class InputBoxManager {
private var pendingPrefixBuffer: String = "" private var pendingPrefixBuffer: String = ""
private var isPrefixResolved: Bool = false private var isPrefixResolved: Bool = false
private var needsNewlineBeforeNextDelta: Bool = false private var needsNewlineBeforeNextDelta: Bool = false
private var sessionGeneration: UInt64 = 0
private var clipboardRestoreWorkItem: DispatchWorkItem?
private let pasteQueue = DispatchQueue(label: "com.soniox.pasteQueue", qos: .userInteractive) private let pasteQueue = DispatchQueue(label: "com.soniox.pasteQueue", qos: .userInteractive)
private static let punctRegex: NSRegularExpression = {
return try! NSRegularExpression(pattern: #"[.,!?:;،؛؟"'()\[\]{}«»–—\-]"#, options: [])
}()
private init() {} private init() {}
public static func stripPunctuation(_ str: String) -> String {
let range = NSRange(location: 0, length: str.utf16.count)
return punctRegex.stringByReplacingMatches(in: str, options: [], range: range, withTemplate: "")
}
/// Reset session state at the beginning of speech /// Reset session state at the beginning of speech
public func resetSession() { public func resetSession() {
lock.lock() lock.lock()
sessionGeneration &+= 1
accumulatedStreamedText = "" accumulatedStreamedText = ""
pendingPrefixBuffer = "" pendingPrefixBuffer = ""
isPrefixResolved = false isPrefixResolved = false
needsNewlineBeforeNextDelta = false needsNewlineBeforeNextDelta = false
clipboardRestoreWorkItem?.cancel()
clipboardRestoreWorkItem = nil
lock.unlock() lock.unlock()
} }
@ -32,8 +46,8 @@ public final class InputBoxManager {
return !s.isEmpty || !p.isEmpty return !s.isEmpty || !p.isEmpty
} }
/// Prepares and guarantees that an input box is focused before recording starts.
/// STRICT RULE: If an input box is already focused, NEVER click or steal focus!
/// Prepares session state before recording starts.
/// STRICT RULE: Focus is 100% user-directed. Never steal focus to browser address bar or toolbar.
public func ensureFocusedInputBox() { public func ensureFocusedInputBox() {
resetSession() resetSession()
@ -43,9 +57,6 @@ public final class InputBoxManager {
print("InputBoxManager: Accessibility not trusted.") print("InputBoxManager: Accessibility not trusted.")
return return
} }
// Focus is 100% user-directed. We preserve whatever the user focused (web text input,
// native text box, code editor, or address bar). Never forcibly steal focus to any toolbar or omnibox.
} }
/// Streams word-by-word delta text directly into the focused input box in real time /// Streams word-by-word delta text directly into the focused input box in real time
@ -54,22 +65,28 @@ public final class InputBoxManager {
// Suppress trailing end triggers ("and", "تمام", "end") from live typing // Suppress trailing end triggers ("and", "تمام", "end") from live typing
let triggerWords = text.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } let triggerWords = text.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
if let last = triggerWords.last?.replacingOccurrences(of: #"[.,!?:;،؛؟"'()\[\]{}«»–—\-]"#, with: "", options: .regularExpression).lowercased(),
last == "and" || last == "تمام" || last == "end" {
if triggerWords.count <= 1 {
return // Drop completely if it was just the end word
if let lastRaw = triggerWords.last {
let last = InputBoxManager.stripPunctuation(lastRaw).lowercased()
if last == "and" || last == "تمام" || last == "end" {
if triggerWords.count <= 1 {
return // Drop completely if it was just the end word
}
let textWithoutEnd = triggerWords.dropLast().joined(separator: " ") + " "
streamDelta(text: textWithoutEnd)
return
} }
let textWithoutEnd = triggerWords.dropLast().joined(separator: " ") + " "
streamDelta(text: textWithoutEnd)
return
} }
// Apply lightweight inline domain vocabulary so streamed words match final text
let normalizedDelta = SonioxSettings.shared.domainVocabulary ? TextProcessor.shared.applyDomainVocabularyInline(text) : text
lock.lock() lock.lock()
let expectedGen = sessionGeneration
if isPrefixResolved { if isPrefixResolved {
var textToPaste = text
var textToPaste = normalizedDelta
if needsNewlineBeforeNextDelta { if needsNewlineBeforeNextDelta {
needsNewlineBeforeNextDelta = false needsNewlineBeforeNextDelta = false
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmed = normalizedDelta.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { if !trimmed.isEmpty {
textToPaste = "\n" + trimmed textToPaste = "\n" + trimmed
} }
@ -79,6 +96,11 @@ public final class InputBoxManager {
pasteQueue.async { [weak self] in pasteQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.lock.lock()
let valid = (self.sessionGeneration == expectedGen)
self.lock.unlock()
guard valid else { return }
let pb = NSPasteboard.general let pb = NSPasteboard.general
pb.clearContents() pb.clearContents()
pb.setString(textToPaste, forType: .string) pb.setString(textToPaste, forType: .string)
@ -89,7 +111,7 @@ public final class InputBoxManager {
} }
// Not resolved yet: buffer to check if speech starts with a slash command // Not resolved yet: buffer to check if speech starts with a slash command
pendingPrefixBuffer += text
pendingPrefixBuffer += normalizedDelta
let trimmed = pendingPrefixBuffer.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = pendingPrefixBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
let words = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } let words = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
@ -111,6 +133,11 @@ public final class InputBoxManager {
pasteQueue.async { [weak self] in pasteQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.lock.lock()
let valid = (self.sessionGeneration == expectedGen)
self.lock.unlock()
guard valid else { return }
let pb = NSPasteboard.general let pb = NSPasteboard.general
pb.clearContents() pb.clearContents()
pb.setString(toFlush, forType: .string) pb.setString(toFlush, forType: .string)
@ -137,6 +164,11 @@ public final class InputBoxManager {
pasteQueue.async { [weak self] in pasteQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.lock.lock()
let valid = (self.sessionGeneration == expectedGen)
self.lock.unlock()
guard valid else { return }
let pb = NSPasteboard.general let pb = NSPasteboard.general
pb.clearContents() pb.clearContents()
pb.setString(textToPaste, forType: .string) pb.setString(textToPaste, forType: .string)
@ -164,6 +196,11 @@ public final class InputBoxManager {
pasteQueue.async { [weak self] in pasteQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.lock.lock()
let valid = (self.sessionGeneration == expectedGen)
self.lock.unlock()
guard valid else { return }
let pb = NSPasteboard.general let pb = NSPasteboard.general
pb.clearContents() pb.clearContents()
pb.setString(toFlush, forType: .string) pb.setString(toFlush, forType: .string)
@ -189,6 +226,7 @@ public final class InputBoxManager {
pb.setString(textWithTrailing, forType: .string) pb.setString(textWithTrailing, forType: .string)
lock.lock() lock.lock()
let expectedGen = sessionGeneration
// Flush any pending buffer if recording ended before prefix resolution // Flush any pending buffer if recording ended before prefix resolution
if !isPrefixResolved && !pendingPrefixBuffer.isEmpty { if !isPrefixResolved && !pendingPrefixBuffer.isEmpty {
let pending = pendingPrefixBuffer let pending = pendingPrefixBuffer
@ -207,14 +245,29 @@ public final class InputBoxManager {
pasteQueue.async { [weak self] in pasteQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.lock.lock()
let valid = (self.sessionGeneration == expectedGen)
self.lock.unlock()
guard valid else { return }
// Helper to restore full text onto clipboard for the user after injection completes
let restoreClipboardFullText = {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
// 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
guard let self = self else { return }
self.lock.lock()
let stillValid = (self.sessionGeneration == expectedGen)
self.lock.unlock()
guard stillValid else { return }
let pb = NSPasteboard.general let pb = NSPasteboard.general
pb.clearContents() pb.clearContents()
pb.setString(textWithTrailing, forType: .string) pb.setString(textWithTrailing, forType: .string)
} }
self.lock.lock()
self.clipboardRestoreWorkItem?.cancel()
self.clipboardRestoreWorkItem = work
self.lock.unlock()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
} }
// Case 1: Nothing was streamed live -> Paste entire text // Case 1: Nothing was streamed live -> Paste entire text
@ -251,7 +304,7 @@ public final class InputBoxManager {
return return
} }
// Case 4: Intelligent Word-level Tail-Anchor Matching (robust to domain vocabulary, number formatting, punctuation, ZWNJ)
// Case 4: Intelligent Word-level Tail-Anchor Matching
let streamedWords = cleanStreamed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } let streamedWords = cleanStreamed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
let finalWords = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } let finalWords = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
@ -261,10 +314,8 @@ public final class InputBoxManager {
} }
let cleanWord: (String) -> String = { w in let cleanWord: (String) -> String = { w in
w.replacingOccurrences(of: "\u{200C}", with: "")
.replacingOccurrences(of: "\u{200D}", with: "")
.replacingOccurrences(of: "[.,!?:;،؛؟\\\"'()\\[\\]{}«»–—\\-]", with: "", options: .regularExpression)
.lowercased()
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 normStreamed = streamedWords.map(cleanWord).filter { !$0.isEmpty }
let normFinal = finalWords.map(cleanWord).filter { !$0.isEmpty } let normFinal = finalWords.map(cleanWord).filter { !$0.isEmpty }
@ -336,10 +387,9 @@ public final class InputBoxManager {
unstreamedStartIndex = min(streamedWords.count, finalWords.count) unstreamedStartIndex = min(streamedWords.count, finalWords.count)
} }
// Suffix Evaluation: NEVER suppress paste! Always paste all remaining unstreamed words!
// Suffix Evaluation: Always paste all remaining unstreamed words
if let startIndex = unstreamedStartIndex { if let startIndex = unstreamedStartIndex {
if startIndex < finalWords.count { if startIndex < finalWords.count {
// Only paste unstreamed trailing words
let remainingWords = Array(finalWords.dropFirst(startIndex)) let remainingWords = Array(finalWords.dropFirst(startIndex))
let suffix = remainingWords.joined(separator: " ") let suffix = remainingWords.joined(separator: " ")
let suffixToPaste = addTrailingSpace ? (" " + suffix + " ") : (" " + suffix) let suffixToPaste = addTrailingSpace ? (" " + suffix + " ") : (" " + suffix)
@ -350,7 +400,6 @@ public final class InputBoxManager {
restoreClipboardFullText() restoreClipboardFullText()
return return
} else { } else {
// startIndex >= finalWords.count: All words were ALREADY streamed!
if addTrailingSpace { if addTrailingSpace {
let pb = NSPasteboard.general let pb = NSPasteboard.general
pb.clearContents() pb.clearContents()
@ -377,6 +426,4 @@ public final class InputBoxManager {
up.post(tap: .cgAnnotatedSessionEventTap) up.post(tap: .cgAnnotatedSessionEventTap)
} }
} }
} }

109
src/PingMonitor.swift

@ -1,5 +1,6 @@
import Foundation import Foundation
import AppKit import AppKit
import Network
public enum PingQuality { public enum PingQuality {
case good(ms: Int) // < 450 ms -> Emerald Green case good(ms: Int) // < 450 ms -> Emerald Green
@ -20,6 +21,11 @@ public enum PingQuality {
} }
} }
} }
public var isConnected: Bool {
if case .disconnected = self { return false }
return true
}
} }
public final class PingMonitor { public final class PingMonitor {
@ -30,21 +36,62 @@ public final class PingMonitor {
private var timer: Timer? private var timer: Timer?
private var isRunning = false private var isRunning = false
private var isMeasuring = false private var isMeasuring = false
private var isActiveCadence = false
private let pathMonitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "com.soniox.pathmonitor", qos: .utility)
public private(set) var isPathSatisfied: Bool = true
private var lastInterfaceSignature: String = ""
public var onPingUpdate: ((PingQuality) -> Void)? public var onPingUpdate: ((PingQuality) -> Void)?
public var onNetworkRestored: (() -> Void)?
public var onInterfaceChanged: (() -> Void)?
public private(set) var currentQuality: PingQuality = .medium(ms: 380) public private(set) var currentQuality: PingQuality = .medium(ms: 380)
private init() { private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 2.5
config.timeoutIntervalForResource = 2.5
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 2.2
config.timeoutIntervalForResource = 2.2
config.waitsForConnectivity = false config.waitsForConnectivity = false
config.requestCachePolicy = .reloadIgnoringLocalCacheData config.requestCachePolicy = .reloadIgnoringLocalCacheData
self.session = URLSession(configuration: config) self.session = URLSession(configuration: config)
setupPathMonitor()
setupSleepWakeObservers() setupSleepWakeObservers()
} }
private func setupPathMonitor() {
pathMonitor.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }
let satisfied = (path.status == .satisfied)
let sig = path.availableInterfaces.map { "\($0.name):\($0.type)" }.joined(separator: ",")
DispatchQueue.main.async {
let wasSatisfied = self.isPathSatisfied
let prevSig = self.lastInterfaceSignature
self.isPathSatisfied = satisfied
self.lastInterfaceSignature = sig
if !satisfied {
print("PingMonitor: NWPathMonitor detected network offline.")
self.currentQuality = .disconnected(ms: nil)
self.onPingUpdate?(.disconnected(ms: nil))
} else {
if !wasSatisfied {
print("PingMonitor: NWPathMonitor detected network RESTORED!")
self.measurePing()
self.onNetworkRestored?()
} else if !prevSig.isEmpty && prevSig != sig {
print("PingMonitor: Network interface changed (\(prevSig) -> \(sig)). Re-measuring...")
self.measurePing()
self.onInterfaceChanged?()
}
}
}
}
pathMonitor.start(queue: monitorQueue)
}
private func setupSleepWakeObservers() { private func setupSleepWakeObservers() {
NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] _ in NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] _ in
print("PingMonitor: Mac entering sleep. Pausing ping measurements.") print("PingMonitor: Mac entering sleep. Pausing ping measurements.")
@ -52,20 +99,43 @@ public final class PingMonitor {
} }
NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { [weak self] _ in NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { [weak self] _ in
print("PingMonitor: Mac woke from sleep. Resuming ping in 2.5s after network settles...")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
print("PingMonitor: Mac woke from sleep. Resuming ping in 2.0s after network settles...")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self?.start() self?.start()
} }
} }
} }
/// Starts monitoring in low-power idle mode (0% CPU drain when not recording)
public func start() { public func start() {
guard !isRunning else { return } guard !isRunning else { return }
isRunning = true isRunning = true
measurePing() measurePing()
scheduleTimer(interval: isActiveCadence ? 1.0 : 45.0)
}
/// Switches between fast 1.0s cadence (while HUD is active) and 45.0s eco cadence (when idle)
public func setActiveRecordingMode(_ active: Bool) {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.timer?.invalidate()
self?.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
guard self.isActiveCadence != active else { return }
self.isActiveCadence = active
if self.isRunning {
if active {
self.measurePing()
self.scheduleTimer(interval: 1.0)
} else {
self.scheduleTimer(interval: 45.0)
}
}
}
}
private func scheduleTimer(interval: TimeInterval) {
DispatchQueue.main.async { [weak self] in
guard let self = self, self.isRunning else { return }
self.timer?.invalidate()
self.timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
self?.measurePing() self?.measurePing()
} }
} }
@ -74,12 +144,22 @@ public final class PingMonitor {
public func stop() { public func stop() {
isRunning = false isRunning = false
isMeasuring = false isMeasuring = false
timer?.invalidate()
timer = nil
isActiveCadence = false
DispatchQueue.main.async { [weak self] in
self?.timer?.invalidate()
self?.timer = nil
}
} }
private func measurePing() {
public func measurePing() {
guard isRunning, !isMeasuring else { return } guard isRunning, !isMeasuring else { return }
if !isPathSatisfied {
DispatchQueue.main.async { [weak self] in
self?.currentQuality = .disconnected(ms: nil)
self?.onPingUpdate?(.disconnected(ms: nil))
}
return
}
isMeasuring = true isMeasuring = true
var req = URLRequest(url: targetURL) var req = URLRequest(url: targetURL)
@ -88,9 +168,11 @@ public final class PingMonitor {
let task = session.dataTask(with: req) { [weak self] _, resp, _ in let task = session.dataTask(with: req) { [weak self] _, resp, _ in
defer { self?.isMeasuring = false } defer { self?.isMeasuring = false }
guard let self = self else { return }
let elapsed = Date().timeIntervalSince(t0) * 1000.0 let elapsed = Date().timeIntervalSince(t0) * 1000.0
let ms = Int(elapsed) let ms = Int(elapsed)
let wasDisconnected = !self.currentQuality.isConnected
let quality: PingQuality let quality: PingQuality
if let http = resp as? HTTPURLResponse, (200...399).contains(http.statusCode) { if let http = resp as? HTTPURLResponse, (200...399).contains(http.statusCode) {
if ms < 450 { if ms < 450 {
@ -107,8 +189,11 @@ public final class PingMonitor {
} }
DispatchQueue.main.async { DispatchQueue.main.async {
self?.currentQuality = quality
self?.onPingUpdate?(quality)
self.currentQuality = quality
self.onPingUpdate?(quality)
if wasDisconnected && quality.isConnected {
self.onNetworkRestored?()
}
} }
} }
task.resume() task.resume()

457
src/SonioxClient.swift

@ -7,6 +7,15 @@ 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"
// Pre-compiled static regexes for zero-allocation hot path
private static let faRegex: NSRegularExpression = {
return try! NSRegularExpression(pattern: "[\\u0600-\\u06FF\\u0750-\\u077F\\uFB50-\\uFDFF\\uFE70-\\uFEFF]", options: [])
}()
private static let enRegex: NSRegularExpression = {
return try! NSRegularExpression(pattern: "[a-zA-Z]", options: [])
}()
private static let punctCharSet = CharacterSet(charactersIn: ".,!?:;،؛؟\"'()[]{}«»-–—")
public let targetLanguage: String? public let targetLanguage: String?
public var isTranslationMode: Bool { public var isTranslationMode: Bool {
guard let target = targetLanguage, !target.isEmpty, target != "off" else { return false } guard let target = targetLanguage, !target.isEmpty, target != "off" else { return false }
@ -18,12 +27,16 @@ public final class SonioxLiveSession {
private var isFinalizing = false private var isFinalizing = false
private var isClosed = false private var isClosed = false
private var isConnected = false private var isConnected = false
private var isAborted = false
private let lock = NSLock() private let lock = NSLock()
// Transcripts tracking
// Transcripts & Stall tracking
private var committedFinalTokens: [String] = [] private var committedFinalTokens: [String] = []
private var currentNonFinalTokens: [String] = [] private var currentNonFinalTokens: [String] = []
private var streamedPrefix: String = "" private var streamedPrefix: String = ""
private var sentAudioBytes: Int = 0
public private(set) var totalReceivedTokensCount: Int = 0
public private(set) var hasSilentStall: Bool = false
// Callbacks // Callbacks
public var onPartialText: ((String) -> Void)? public var onPartialText: ((String) -> Void)?
@ -41,13 +54,22 @@ public final class SonioxLiveSession {
public func setRecordingActive(_ active: Bool) { public func setRecordingActive(_ active: Bool) {
lock.lock() lock.lock()
isRecordingActive = active isRecordingActive = active
if active {
lastDataTime = Date()
}
lock.unlock() lock.unlock()
} }
public var isReady: Bool { public var isReady: Bool {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
return isConnected && !isClosed && !isFinalizing
return isConnected && !isClosed && !isFinalizing && !isAborted
}
public var isAlive: Bool {
lock.lock()
defer { lock.unlock() }
return !isClosed && !isAborted && !hasNetworkFailure
} }
public func startKeepAliveHeartbeat() { public func startKeepAliveHeartbeat() {
@ -57,12 +79,13 @@ public final class SonioxLiveSession {
self.heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 6.0, repeats: true) { [weak self] _ in self.heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 6.0, repeats: true) { [weak self] _ in
guard let self = self else { return } guard let self = self else { return }
self.lock.lock() self.lock.lock()
guard !self.isClosed, !self.isFinalizing, let task = self.webSocketTask else {
guard !self.isClosed, !self.isFinalizing, !self.isAborted, let task = self.webSocketTask else {
self.lock.unlock() self.lock.unlock()
return return
} }
self.lock.unlock() self.lock.unlock()
let t0 = Date()
task.sendPing { [weak self] error in task.sendPing { [weak self] error in
guard let self = self else { return } guard let self = self else { return }
if let error = error { if let error = error {
@ -73,6 +96,14 @@ public final class SonioxLiveSession {
self.isClosed = true self.isClosed = true
self.lock.unlock() self.lock.unlock()
SonioxSessionPool.shared.prewarmNextSession() SonioxSessionPool.shared.prewarmNextSession()
} else {
let rtt = Date().timeIntervalSince(t0)
if rtt > 0.95 {
self.lock.lock()
self.hasSilentStall = true
self.lock.unlock()
self.onNetworkDegraded?(true)
}
} }
} }
} }
@ -112,10 +143,10 @@ public final class SonioxLiveSession {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.setValue(SonioxLiveSession.userAgent, forHTTPHeaderField: "User-Agent") request.setValue(SonioxLiveSession.userAgent, forHTTPHeaderField: "User-Agent")
request.setValue(SonioxLiveSession.origin, forHTTPHeaderField: "Origin") request.setValue(SonioxLiveSession.origin, forHTTPHeaderField: "Origin")
request.timeoutInterval = 25.0
request.timeoutInterval = 12.0
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
config.waitsForConnectivity = false // Fail fast on dead interface so recovery/failover triggers immediately
config.requestCachePolicy = .reloadIgnoringLocalCacheData config.requestCachePolicy = .reloadIgnoringLocalCacheData
let session = URLSession(configuration: config) let session = URLSession(configuration: config)
@ -128,8 +159,9 @@ public final class SonioxLiveSession {
task.sendPing { [weak self] error in task.sendPing { [weak self] error in
guard let self = self else { return } guard let self = self else { return }
self.lock.lock() self.lock.lock()
if error == nil && !self.isClosed {
if error == nil && !self.isClosed && !self.isAborted {
self.isConnected = true self.isConnected = true
self.lastDataTime = Date()
self.lock.unlock() self.lock.unlock()
self.onConnectionStateChanged?(true) self.onConnectionStateChanged?(true)
print("SonioxLiveSession: Connected successfully.") print("SonioxLiveSession: Connected successfully.")
@ -146,27 +178,59 @@ public final class SonioxLiveSession {
startReceiving() startReceiving()
} }
/// Called while user is actively speaking to detect half-open TCP silent stalls
public func checkSilentStallWhileSpeaking() {
lock.lock()
let connected = isConnected && !isClosed && !isAborted
let sent = sentAudioBytes
let elapsedSinceData = Date().timeIntervalSince(lastDataTime)
lock.unlock()
// If >1.5s of audio (48KB) was sent while speaking, but 0 frames arrived for >3.0s:
if connected && sent > 48000 && elapsedSinceData > 3.0 {
lock.lock()
hasSilentStall = true
lock.unlock()
DispatchQueue.main.async { [weak self] in
self?.onNetworkDegraded?(true)
}
}
}
public func sendAudioChunk(_ data: Data) { public func sendAudioChunk(_ data: Data) {
lock.lock() lock.lock()
defer { lock.unlock() }
guard !isFinalizing, !isClosed, let task = webSocketTask else { return }
guard !isFinalizing, !isClosed, !isAborted, let task = webSocketTask else {
lock.unlock()
return
}
lock.unlock()
let t0 = Date()
let message = URLSessionWebSocketTask.Message.data(data) let message = URLSessionWebSocketTask.Message.data(data)
task.send(message) { [weak self] error in task.send(message) { [weak self] error in
guard let self = self else { return }
if let error = error { if let error = error {
print("SonioxLiveSession: Error streaming audio chunk:", error) print("SonioxLiveSession: Error streaming audio chunk:", error)
self?.lock.lock()
self?.hasNetworkFailure = true
self?.isConnected = false
self?.lock.unlock()
self?.onNetworkDegraded?(true)
self.lock.lock()
self.hasNetworkFailure = true
self.isConnected = false
self.lock.unlock()
self.onNetworkDegraded?(true)
} else {
self.lock.lock()
self.sentAudioBytes += data.count
let sendDur = Date().timeIntervalSince(t0)
if sendDur > 0.70 {
self.hasSilentStall = true
}
self.lock.unlock()
} }
} }
} }
public func finalizeStream() { public func finalizeStream() {
lock.lock() lock.lock()
guard !isFinalizing, !isClosed, let task = webSocketTask else {
guard !isFinalizing, !isClosed, !isAborted, let task = webSocketTask else {
lock.unlock() lock.unlock()
return return
} }
@ -175,7 +239,6 @@ public final class SonioxLiveSession {
print("SonioxLiveSession: Sending finalize packet...") print("SonioxLiveSession: Sending finalize packet...")
// 1. Send trailing 300ms (9600 bytes at 16kHz 16-bit PCM) silence frame // 1. Send trailing 300ms (9600 bytes at 16kHz 16-bit PCM) silence frame
// so acoustic CTC model cleanly flushes and resolves trailing fast speech syllables
let silenceChunk = Data(count: 9600) let silenceChunk = Data(count: 9600)
let silenceMsg = URLSessionWebSocketTask.Message.data(silenceChunk) let silenceMsg = URLSessionWebSocketTask.Message.data(silenceChunk)
task.send(silenceMsg) { _ in } task.send(silenceMsg) { _ in }
@ -192,14 +255,14 @@ public final class SonioxLiveSession {
} }
} }
// Safety timeout fallback: allow up to 4.5s for STT inference & network transit
DispatchQueue.global().asyncAfter(deadline: .now() + 4.5) { [weak self] in
// Safety timeout fallback: allow up to 4.0s for STT inference & network transit
DispatchQueue.global().asyncAfter(deadline: .now() + 4.0) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.lock.lock() self.lock.lock()
let stillOpen = !self.isClosed
let stillOpen = !self.isClosed && !self.isAborted
self.lock.unlock() self.lock.unlock()
if stillOpen { if stillOpen {
print("SonioxLiveSession: Finalize timeout (4.5s) reached — completing.")
print("SonioxLiveSession: Finalize timeout (4.0s) reached — completing.")
self.completeWithCurrentText() self.completeWithCurrentText()
} }
} }
@ -211,7 +274,7 @@ public final class SonioxLiveSession {
guard let self = self else { return } guard let self = self else { return }
self.lock.lock() self.lock.lock()
if self.isClosed {
if self.isClosed || self.isAborted {
self.lock.unlock() self.lock.unlock()
return return
} }
@ -219,7 +282,12 @@ public final class SonioxLiveSession {
switch result { switch result {
case .success(let message): case .success(let message):
self.lock.lock()
self.lastDataTime = Date() self.lastDataTime = Date()
self.hasSilentStall = false
self.lock.unlock()
self.onNetworkDegraded?(false)
var textReceived: String? var textReceived: String?
switch message { switch message {
case .string(let str): case .string(let str):
@ -244,8 +312,6 @@ public final class SonioxLiveSession {
self.lock.unlock() self.lock.unlock()
self.onNetworkDegraded?(true) self.onNetworkDegraded?(true)
// If recording is still active (user speaking), keep buffering in AudioRecorder;
// do NOT complete or deliver empty text prematurely!
if !activeRecording { if !activeRecording {
self.completeWithCurrentText() self.completeWithCurrentText()
} else { } else {
@ -261,19 +327,16 @@ public final class SonioxLiveSession {
var gotFin = false var gotFin = false
var newFinals: [String] = [] var newFinals: [String] = []
var newNonFinals: [String] = [] var newNonFinals: [String] = []
var tokenCountDelta = 0
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 isTranslation = (part["translation_status"] as? String) == "translation" let isTranslation = (part["translation_status"] as? String) == "translation"
if self.isTranslationMode { if self.isTranslationMode {
if !isTranslation {
continue
}
if !isTranslation { continue }
} else { } else {
if isTranslation {
continue
}
if isTranslation { continue }
} }
let pText = part["text"] as? String ?? "" let pText = part["text"] as? String ?? ""
@ -284,19 +347,27 @@ public final class SonioxLiveSession {
let clean = pText.replacingOccurrences(of: "<fin>", with: "") let clean = pText.replacingOccurrences(of: "<fin>", with: "")
if !clean.isEmpty { if !clean.isEmpty {
newFinals.append(clean) newFinals.append(clean)
tokenCountDelta += 1
} }
} else if isFinal { } else if isFinal {
if !pText.isEmpty { if !pText.isEmpty {
newFinals.append(pText) newFinals.append(pText)
tokenCountDelta += 1
} }
} else { } else {
if !pText.isEmpty { if !pText.isEmpty {
newNonFinals.append(pText) newNonFinals.append(pText)
tokenCountDelta += 1
} }
} }
} }
lock.lock() lock.lock()
if isAborted {
lock.unlock()
return
}
totalReceivedTokensCount += tokenCountDelta
if !newFinals.isEmpty { if !newFinals.isEmpty {
committedFinalTokens.append(contentsOf: newFinals) committedFinalTokens.append(contentsOf: newFinals)
} }
@ -311,7 +382,7 @@ public final class SonioxLiveSession {
let trimmedCombined = combined.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedCombined = combined.trimmingCharacters(in: .whitespacesAndNewlines)
let currentWords = trimmedCombined.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } let currentWords = trimmedCombined.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
if let lastRaw = currentWords.last { if let lastRaw = currentWords.last {
let cleanLast = lastRaw.replacingOccurrences(of: #"[.,!?:;،؛؟"'()\[\]{}«»–—\-]"#, with: "", options: .regularExpression).lowercased()
let cleanLast = InputBoxManager.stripPunctuation(lastRaw).lowercased()
if cleanLast == "and" || cleanLast == "تمام" || cleanLast == "end" { if cleanLast == "and" || cleanLast == "تمام" || cleanLast == "end" {
triggerDetected = true triggerDetected = true
} }
@ -360,9 +431,18 @@ public final class SonioxLiveSession {
} }
} }
public func currentBestTranscript() -> String {
lock.lock()
let fullCommitted = committedFinalTokens.joined()
let fullNonFinal = currentNonFinalTokens.joined()
let rawCombined = fullCommitted.isEmpty ? fullNonFinal : (fullCommitted + fullNonFinal)
lock.unlock()
return SonioxLiveSession.sanitizeText(rawCombined)
}
public func completeWithCurrentText() { public func completeWithCurrentText() {
lock.lock() lock.lock()
if isClosed {
if isClosed || isAborted {
lock.unlock() lock.unlock()
return return
} }
@ -376,9 +456,12 @@ public final class SonioxLiveSession {
let cb = onFinalResult let cb = onFinalResult
webSocketTask?.cancel(with: .normalClosure, reason: nil) webSocketTask?.cancel(with: .normalClosure, reason: nil)
webSocketTask = nil webSocketTask = nil
urlSession?.finishTasksAndInvalidate()
urlSession = nil urlSession = nil
lock.unlock() lock.unlock()
stopKeepAliveHeartbeat()
DispatchQueue.main.async { DispatchQueue.main.async {
if cleaned.isEmpty && failureOccurred { if cleaned.isEmpty && failureOccurred {
cb?(.failure(NSError(domain: "SonioxLiveSession", code: -101, userInfo: [NSLocalizedDescriptionKey: "WebSocket network failure with empty transcript"]))) cb?(.failure(NSError(domain: "SonioxLiveSession", code: -101, userInfo: [NSLocalizedDescriptionKey: "WebSocket network failure with empty transcript"])))
@ -388,13 +471,33 @@ public final class SonioxLiveSession {
} }
} }
public func cancel() {
/// Immediate hard cancellation (Escape key pressed) — suppresses all further callbacks
public func abort() {
lock.lock() lock.lock()
isAborted = true
isClosed = true isClosed = true
webSocketTask?.cancel(with: .normalClosure, reason: nil)
isConnected = false
isRecordingActive = false
isFinalizing = false
onPartialText = nil
onCommittedDelta = nil
onEndTriggerDetected = nil
onFinalResult = nil
onConnectionStateChanged = nil
onNetworkDegraded = nil
let task = webSocketTask
let session = urlSession
webSocketTask = nil webSocketTask = nil
urlSession = nil urlSession = nil
lock.unlock() lock.unlock()
stopKeepAliveHeartbeat()
task?.cancel()
session?.invalidateAndCancel()
}
public func cancel() {
abort()
} }
// MARK: - Smart Audio Recovery / Fast Burst Retry // MARK: - Smart Audio Recovery / Fast Burst Retry
@ -402,13 +505,15 @@ public final class SonioxLiveSession {
/// Re-transcribes the full audio buffer when the live stream had packet drops, disconnects, or missing trailing words. /// Re-transcribes the full audio buffer when the live stream had packet drops, disconnects, or missing trailing words.
public static func retryFullBufferedAudio( public static func retryFullBufferedAudio(
pcmData: Data, pcmData: Data,
languageHints: [String],
languageHints: [String] = SonioxSettings.shared.language.hints,
targetLanguage: String? = nil, targetLanguage: String? = nil,
isCancelled: @escaping () -> Bool = { false },
onProgress: ((String) -> Void)? = nil, onProgress: ((String) -> Void)? = nil,
onStatusUpdate: ((String) -> Void)? = nil,
completion: @escaping (Result<String, Error>) -> Void completion: @escaping (Result<String, Error>) -> Void
) { ) {
guard !pcmData.isEmpty else {
completion(.failure(NSError(domain: "SonioxLiveSession", code: -102, userInfo: [NSLocalizedDescriptionKey: "Empty audio buffer for recovery"])))
guard !pcmData.isEmpty, !isCancelled() else {
completion(.failure(NSError(domain: "SonioxLiveSession", code: -102, userInfo: [NSLocalizedDescriptionKey: "Cancelled or empty audio buffer"])))
return return
} }
@ -423,25 +528,62 @@ public final class SonioxLiveSession {
// Attempt 4: Fallback WS // Attempt 4: Fallback WS
func executeAttempt(_ step: Int) { func executeAttempt(_ step: Int) {
if isCancelled() {
completion(.failure(NSError(domain: "SonioxLiveSession", code: -999, userInfo: [NSLocalizedDescriptionKey: "Cancelled by Escape"])))
return
}
let isPrimary = (step == 1 || step == 3) let isPrimary = (step == 1 || step == 3)
let endpoint = isPrimary ? primaryWsBase : fallbackWsBase let endpoint = isPrimary ? primaryWsBase : fallbackWsBase
let name = isPrimary ? "Primary" : "Fallback" let name = isPrimary ? "Primary" : "Fallback"
print("SonioxLiveSession: Smart Recovery Attempt \(step)/4 on \(name) endpoint...") print("SonioxLiveSession: Smart Recovery Attempt \(step)/4 on \(name) endpoint...")
executeBurstSession(endpoint: endpoint, pcmData: pcmData, languageHints: languageHints, targetLanguage: effectiveTarget, onProgress: onProgress) { result in
if step > 1 {
DispatchQueue.main.async {
onStatusUpdate?("RETRY \(step)/4")
}
}
// If network interface is completely offline on attempt >= 2, wait briefly for Wi-Fi/VPN handoff
if !PingMonitor.shared.isPathSatisfied && step >= 2 {
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 1.0) {
if isCancelled() {
completion(.failure(NSError(domain: "SonioxLiveSession", code: -999, userInfo: [NSLocalizedDescriptionKey: "Cancelled"])))
return
}
if step < 4 {
executeAttempt(step + 1)
} else {
completion(.failure(NSError(domain: "SonioxLiveSession", code: -1009, userInfo: [NSLocalizedDescriptionKey: "Network offline"])))
}
}
return
}
executeBurstSession(
endpoint: endpoint,
pcmData: pcmData,
languageHints: languageHints,
targetLanguage: effectiveTarget,
isCancelled: isCancelled,
onProgress: onProgress
) { result in
if isCancelled() {
completion(.failure(NSError(domain: "SonioxLiveSession", code: -999, userInfo: [NSLocalizedDescriptionKey: "Cancelled"])))
return
}
switch result { switch result {
case .success(let text): case .success(let text):
print("SonioxLiveSession: Recovery Attempt \(step) SUCCEEDED (\(text.count) chars).")
print("SonioxLiveSession: ✅ Recovery Attempt \(step) SUCCEEDED (\(text.count) chars).")
completion(.success(text)) completion(.success(text))
case .failure(let error): case .failure(let error):
print("SonioxLiveSession: Recovery Attempt \(step) failed: \(error)") print("SonioxLiveSession: Recovery Attempt \(step) failed: \(error)")
if step < 4 { if step < 4 {
let delay = (step == 2) ? 0.8 : 0.2 // 0.8s pause before attempt 3 for network interface stabilization
DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
let delay = (step == 2) ? 0.8 : 0.25
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay) {
executeAttempt(step + 1) executeAttempt(step + 1)
} }
} else { } else {
print("SonioxLiveSession: All 4 recovery attempts exhausted: \(error)")
print("SonioxLiveSession: ❌ All 4 recovery attempts exhausted: \(error)")
completion(.failure(error)) completion(.failure(error))
} }
} }
@ -456,6 +598,7 @@ public final class SonioxLiveSession {
pcmData: Data, pcmData: Data,
languageHints: [String], languageHints: [String],
targetLanguage: String?, targetLanguage: String?,
isCancelled: @escaping () -> Bool,
onProgress: ((String) -> Void)?, onProgress: ((String) -> Void)?,
completion: @escaping (Result<String, Error>) -> Void completion: @escaping (Result<String, Error>) -> Void
) { ) {
@ -483,12 +626,14 @@ public final class SonioxLiveSession {
request.setValue(origin, forHTTPHeaderField: "Origin") request.setValue(origin, forHTTPHeaderField: "Origin")
let durationSec = Double(pcmData.count) / 32000.0 let durationSec = Double(pcmData.count) / 32000.0
// Dynamic watchdog: allow generous time for network reconnect + fast upload + STT inference
let watchdogTimeout = max(8.0, min(30.0, durationSec * 0.75 + 6.0))
request.timeoutInterval = watchdogTimeout + 5.0
let watchdogTimeout = max(6.5, min(25.0, durationSec * 0.60 + 5.0))
request.timeoutInterval = 5.0 // Fast handshake timeout so dead endpoint fails over in <5s
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = watchdogTimeout
config.timeoutIntervalForResource = watchdogTimeout + 3.0
config.waitsForConnectivity = false // Fail fast on dead interface so next endpoint attempt runs immediately!
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
let session = URLSession(configuration: config) let session = URLSession(configuration: config)
let task = session.webSocketTask(with: request) let task = session.webSocketTask(with: request)
task.resume() task.resume()
@ -511,11 +656,18 @@ public final class SonioxLiveSession {
} }
} }
// Receive handler
func receiveNext() { func receiveNext() {
if isCancelled() {
finishOnce(with: .failure(NSError(domain: "Soniox", code: -999, userInfo: [NSLocalizedDescriptionKey: "Cancelled"])))
return
}
task.receive { result in task.receive { result in
lock.lock() lock.lock()
if isDone { lock.unlock(); return }
if isDone || isCancelled() {
lock.unlock()
finishOnce(with: .failure(NSError(domain: "Soniox", code: -999, userInfo: [NSLocalizedDescriptionKey: "Cancelled"])))
return
}
lock.unlock() lock.unlock()
switch result { switch result {
@ -532,6 +684,7 @@ public final class SonioxLiveSession {
var gotFin = false var gotFin = false
var packetNonFinal: [String] = [] var packetNonFinal: [String] = []
var packetCommitted: [String] = []
if let parts = json["parts"] as? [[String: Any]] { if let parts = json["parts"] as? [[String: Any]] {
for p in parts { for p in parts {
let isTranslation = (p["translation_status"] as? String) == "translation" let isTranslation = (p["translation_status"] as? String) == "translation"
@ -545,9 +698,9 @@ public final class SonioxLiveSession {
if t.contains("<fin>") { if t.contains("<fin>") {
gotFin = true gotFin = true
let clean = t.replacingOccurrences(of: "<fin>", with: "") let clean = t.replacingOccurrences(of: "<fin>", with: "")
if !clean.isEmpty { fullCommitted.append(clean) }
if !clean.isEmpty { packetCommitted.append(clean) }
} else if isFinal { } else if isFinal {
if !t.isEmpty { fullCommitted.append(t) }
if !t.isEmpty { packetCommitted.append(t) }
} else { } else {
if !t.isEmpty { packetNonFinal.append(t) } if !t.isEmpty { packetNonFinal.append(t) }
} }
@ -555,6 +708,9 @@ public final class SonioxLiveSession {
} }
lock.lock() lock.lock()
if !packetCommitted.isEmpty {
fullCommitted.append(contentsOf: packetCommitted)
}
latestNonFinal = packetNonFinal latestNonFinal = packetNonFinal
let currentProgress = fullCommitted.joined() + (gotFin ? "" : latestNonFinal.joined()) let currentProgress = fullCommitted.joined() + (gotFin ? "" : latestNonFinal.joined())
lock.unlock() lock.unlock()
@ -598,12 +754,15 @@ public final class SonioxLiveSession {
receiveNext() receiveNext()
// Handshake verification before pumping audio to avoid early drops & empty transcripts
// Handshake verification + Chained Backpressure Upload (32KB / 1.0s chunks)
task.sendPing { pingError in task.sendPing { pingError in
lock.lock() lock.lock()
let alreadyDone = isDone let alreadyDone = isDone
lock.unlock() lock.unlock()
if alreadyDone { return }
if alreadyDone || isCancelled() {
finishOnce(with: .failure(NSError(domain: "Soniox", code: -999, userInfo: nil)))
return
}
if let pingErr = pingError { if let pingErr = pingError {
print("SonioxLiveSession: Burst connection ping failed on \(endpoint): \(pingErr)") print("SonioxLiveSession: Burst connection ping failed on \(endpoint): \(pingErr)")
@ -611,34 +770,33 @@ public final class SonioxLiveSession {
return return
} }
// Connection is active: stream audio in high-speed paced bursts
DispatchQueue.global(qos: .userInitiated).async {
let chunkSize = 16000 // 0.5s audio
var offset = 0
while offset < pcmData.count {
lock.lock()
let done = isDone
lock.unlock()
if done { break }
let length = min(chunkSize, pcmData.count - offset)
let chunk = pcmData.subdata(in: offset..<(offset + length))
task.send(URLSessionWebSocketTask.Message.data(chunk)) { _ in }
offset += length
usleep(12000) // 12ms pace
}
let chunkSize = 32000 // 1.0s of 16kHz 16-bit PCM
func sendNextChunk(offset: Int) {
lock.lock() lock.lock()
let done = isDone let done = isDone
lock.unlock() lock.unlock()
if !done {
// Send trailing silence + finalize
let silence = Data(count: 4800)
task.send(URLSessionWebSocketTask.Message.data(silence)) { _ in }
let finalizeMsg = URLSessionWebSocketTask.Message.string("{\"type\": \"finalize\"}")
task.send(finalizeMsg) { _ in }
if done || isCancelled() { return }
if offset >= pcmData.count {
let silence = Data(count: 6400)
task.send(.data(silence)) { _ in
task.send(.string("{\"type\": \"finalize\"}")) { _ in }
}
return
}
let end = min(offset + chunkSize, pcmData.count)
let slice = pcmData.subdata(in: offset..<end)
task.send(.data(slice)) { sendErr in
if let sendErr = sendErr {
finishOnce(with: .failure(sendErr))
return
}
sendNextChunk(offset: end)
} }
} }
sendNextChunk(offset: 0)
} }
// Dynamic Watchdog timeout // Dynamic Watchdog timeout
@ -657,7 +815,7 @@ public final class SonioxLiveSession {
} }
} }
// MARK: - Sanitization
// MARK: - Sanitization (Pre-compiled Regexes)
public static func sanitizeText(_ text: String) -> String { public static func sanitizeText(_ text: String) -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
@ -666,18 +824,16 @@ public final class SonioxLiveSession {
let words = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } let words = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
if words.isEmpty { return "" } if words.isEmpty { return "" }
let faPattern = "[\\u0600-\\u06FF\\u0750-\\u077F\\uFB50-\\uFDFF\\uFE70-\\uFEFF]"
let enPattern = "[a-zA-Z]"
func matches(_ pattern: String, in str: String) -> Bool {
return str.range(of: pattern, options: .regularExpression) != nil
func matches(_ regex: NSRegularExpression, in str: String) -> Bool {
let range = NSRange(location: 0, length: str.utf16.count)
return regex.firstMatch(in: str, options: [], range: range) != nil
} }
var faCount = 0 var faCount = 0
var enCount = 0 var enCount = 0
for w in words { for w in words {
if matches(faPattern, in: w) { faCount += 1 }
if matches(enPattern, in: w) { enCount += 1 }
if matches(faRegex, in: w) { faCount += 1 }
if matches(enRegex, in: w) { enCount += 1 }
} }
let total = faCount + enCount let total = faCount + enCount
@ -696,8 +852,8 @@ public final class SonioxLiveSession {
if faRatio >= 0.25 { if faRatio >= 0.25 {
for w in words { for w in words {
if matches(enPattern, in: w) && !matches(faPattern, in: w) {
let cleanW = w.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".,!?:;،؛؟\"'()[]{}«»-–—"))
if matches(enRegex, in: w) && !matches(faRegex, in: w) {
let cleanW = w.lowercased().trimmingCharacters(in: punctCharSet)
if stopWords.contains(cleanW) { continue } if stopWords.contains(cleanW) { continue }
if faRatio >= 0.70 && !preserveWords.contains(cleanW) { continue } if faRatio >= 0.70 && !preserveWords.contains(cleanW) { continue }
} }
@ -744,13 +900,14 @@ public final class SonioxSessionPool {
public func discardPrewarmedSession() { public func discardPrewarmedSession() {
lock.lock() lock.lock()
prewarmedSession?.stopKeepAliveHeartbeat() prewarmedSession?.stopKeepAliveHeartbeat()
prewarmedSession?.cancel()
prewarmedSession?.abort()
prewarmedSession = nil prewarmedSession = nil
isPrewarming = false isPrewarming = false
lock.unlock() lock.unlock()
} }
public func prewarmNextSession() { public func prewarmNextSession() {
guard PingMonitor.shared.isPathSatisfied else { return }
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
@ -765,7 +922,7 @@ public final class SonioxSessionPool {
isPrewarming = true isPrewarming = true
prewarmedSession?.stopKeepAliveHeartbeat() prewarmedSession?.stopKeepAliveHeartbeat()
prewarmedSession?.cancel()
prewarmedSession?.abort()
prewarmedSession = nil prewarmedSession = nil
DispatchQueue.global(qos: .userInitiated).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
@ -789,8 +946,7 @@ public final class SonioxSessionPool {
} }
} }
// Watchdog in case connection hangs indefinitely
DispatchQueue.global().asyncAfter(deadline: .now() + 15.0) { [weak self] in
DispatchQueue.global().asyncAfter(deadline: .now() + 12.0) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.lock.lock() self.lock.lock()
if self.isPrewarming && self.prewarmedSession == nil { if self.isPrewarming && self.prewarmedSession == nil {
@ -813,20 +969,19 @@ public final class SonioxSessionPool {
prewarmedSession = nil prewarmedSession = nil
lock.unlock() lock.unlock()
// Immediately start pre-warming the NEXT session in the background
DispatchQueue.global(qos: .userInitiated).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.prewarmNextSession() self?.prewarmNextSession()
} }
let age = Date().timeIntervalSince(createdAt) let age = Date().timeIntervalSince(createdAt)
if let session = session, session.isReady && age < 60.0 {
if let session = session, session.isReady && age < 55.0 {
session.stopKeepAliveHeartbeat() session.stopKeepAliveHeartbeat()
print("SonioxSessionPool: Acquired HOT prewarmed session (age: \(String(format: "%.1f", age))s, 0ms connect delay!)") print("SonioxSessionPool: Acquired HOT prewarmed session (age: \(String(format: "%.1f", age))s, 0ms connect delay!)")
return session return session
} }
session?.stopKeepAliveHeartbeat() session?.stopKeepAliveHeartbeat()
session?.cancel()
session?.abort()
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
@ -834,3 +989,123 @@ public final class SonioxSessionPool {
return SonioxLiveSession(languageHints: hints, targetLanguage: target) return SonioxLiveSession(languageHints: hints, targetLanguage: target)
} }
} }
// MARK: - Persistent Offline Audio Queue (Zero Data Loss on Multi-Minute Internet Outages)
public final class OfflineAudioQueueManager {
public static let shared = OfflineAudioQueueManager()
private let queueDir: URL
private let workerQueue = DispatchQueue(label: "com.soniox.offlinequeue", qos: .utility)
private var isProcessing = false
private let lock = NSLock()
public var onOfflineAudioRecovered: ((String) -> Void)?
private init() {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
self.queueDir = appSupport.appendingPathComponent("SonioxVoice/PendingAudioQueue")
try? FileManager.default.createDirectory(at: queueDir, withIntermediateDirectories: true)
}
public func enqueueFailedAudio(pcmData: Data) {
guard pcmData.count >= 16000 else { return } // Only save meaningful speech (>= 0.5s)
workerQueue.async { [weak self] in
guard let self = self else { return }
try? FileManager.default.createDirectory(at: self.queueDir, withIntermediateDirectories: true)
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
let fileURL = self.queueDir.appendingPathComponent("speech_\(timestamp).pcm")
do {
try pcmData.write(to: fileURL, options: .atomic)
print("OfflineAudioQueueManager: 💾 Saved \(pcmData.count) bytes of speech to disk queue (\(fileURL.lastPathComponent)) for automatic recovery when internet returns.")
} catch {
print("OfflineAudioQueueManager: Failed to write offline audio:", error)
}
}
}
public func clearAllPending() {
workerQueue.async { [weak self] in
guard let self = self else { return }
if let files = try? FileManager.default.contentsOfDirectory(at: self.queueDir, includingPropertiesForKeys: nil) {
for f in files where f.pathExtension == "pcm" {
try? FileManager.default.removeItem(at: f)
}
}
}
}
public func processPendingQueueIfNeeded(isCancelled: @escaping () -> Bool = { false }) {
guard PingMonitor.shared.isPathSatisfied else { return }
lock.lock()
if isProcessing {
lock.unlock()
return
}
isProcessing = true
lock.unlock()
workerQueue.async { [weak self] in
guard let self = self else { return }
guard let files = try? FileManager.default.contentsOfDirectory(at: self.queueDir, includingPropertiesForKeys: [.creationDateKey]),
!files.isEmpty else {
self.lock.lock()
self.isProcessing = false
self.lock.unlock()
return
}
let pcmFiles = files.filter { $0.pathExtension == "pcm" }.sorted { $0.lastPathComponent < $1.lastPathComponent }
guard let firstFile = pcmFiles.first else {
self.lock.lock()
self.isProcessing = false
self.lock.unlock()
return
}
// Drop stale files older than 2 hours
if let attrs = try? FileManager.default.attributesOfItem(atPath: firstFile.path),
let created = attrs[.creationDate] as? Date,
Date().timeIntervalSince(created) > 7200 {
try? FileManager.default.removeItem(at: firstFile)
self.lock.lock()
self.isProcessing = false
self.lock.unlock()
self.processPendingQueueIfNeeded(isCancelled: isCancelled)
return
}
guard let pcmData = try? Data(contentsOf: firstFile), !pcmData.isEmpty else {
try? FileManager.default.removeItem(at: firstFile)
self.lock.lock()
self.isProcessing = false
self.lock.unlock()
return
}
print("OfflineAudioQueueManager: 🌐 Network is back! Recovering queued speech file \(firstFile.lastPathComponent) (\(pcmData.count) bytes)...")
SonioxLiveSession.retryFullBufferedAudio(pcmData: pcmData, isCancelled: isCancelled) { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let recoveredText):
let trimmed = recoveredText.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
try? FileManager.default.removeItem(at: firstFile)
DispatchQueue.main.async {
self.onOfflineAudioRecovered?(trimmed)
}
self.lock.lock()
self.isProcessing = false
self.lock.unlock()
self.processPendingQueueIfNeeded(isCancelled: isCancelled)
return
}
case .failure:
break
}
self.lock.lock()
self.isProcessing = false
self.lock.unlock()
}
}
}
}

123
src/TextProcessor.swift

@ -3,6 +3,14 @@ import Foundation
public final class TextProcessor { public final class TextProcessor {
public static let shared = TextProcessor() public static let shared = TextProcessor()
private static let trailingEndRegex: NSRegularExpression = {
return try! NSRegularExpression(pattern: #"[،,:\s]+(and|تمام|end)[.!?]?$"#, options: .caseInsensitive)
}()
private static let singleEndRegex: NSRegularExpression = {
return try! NSRegularExpression(pattern: #"^(and|تمام|end)[.!?]?$"#, options: .caseInsensitive)
}()
private init() {} private init() {}
public func process(text: String) -> String { public func process(text: String) -> String {
@ -63,6 +71,10 @@ public final class TextProcessor {
return result.trimmingCharacters(in: .whitespacesAndNewlines) return result.trimmingCharacters(in: .whitespacesAndNewlines)
} }
public func applyDomainVocabularyInline(_ text: String) -> String {
return applyDomainVocabulary(text)
}
private func applyVoicePunctuation(_ text: String) -> String { private func applyVoicePunctuation(_ text: String) -> String {
var t = " " + text + " " var t = " " + text + " "
@ -120,51 +132,51 @@ public final class TextProcessor {
return t return t
} }
private static let domainTerms: [(String, String)] = [
("این اپ پرچس", "In-App Purchase"),
("این اپ پرشرز", "In-App Purchase"),
("اپ پرشرز", "In-App Purchases"),
("اپل کانکت", "App Store Connect"),
("اپ استور کانکت", "App Store Connect"),
("حجزی مرچنت", "Hajzee Merchant"),
("حجزی یوزر", "Hajzee User"),
("حجزی", "Hajzee"),
("نیوهورایزن", "NewHorizon"),
("نیو هورایزن", "NewHorizon"),
("نیوهوریزن", "NewHorizon"),
("زر محک", "ZarMahak"),
("زرمحک", "ZarMahak"),
("سانی اوکس", "Soniox"),
("سانیوکس", "Soniox"),
("سانی اکس", "Soniox"),
("تیم بای", "TeamBy"),
("تیمبی", "TeamBy"),
("دوودی", "Dovodi"),
("داودی", "Dovodi"),
("داکر", "Docker"),
("کوبرنتیز", "Kubernetes"),
("کوبرنتس", "Kubernetes"),
("ای اس ایکس آی", "ESXi"),
("ای اس ایکس ای", "ESXi"),
("فست ای پی آی", "FastAPI"),
("فست ای پی ای", "FastAPI"),
("پستگرس کیو ال", "PostgreSQL"),
("پستگرس", "PostgreSQL"),
("ردیس", "Redis"),
("فلاتر", "Flutter"),
("وایارگارد", "WireGuard"),
("وایرگارد", "WireGuard"),
("میکروتیک", "MikroTik"),
("هرمس", "Hermes"),
("نکست جی اس", "Next.js"),
("ری اکت", "React"),
("پایتون", "Python"),
("سویفت", "Swift")
]
private func applyDomainVocabulary(_ text: String) -> String { private func applyDomainVocabulary(_ text: String) -> String {
var t = text var t = text
let terms: [(String, String)] = [
("این اپ پرچس", "In-App Purchase"),
("این اپ پرشرز", "In-App Purchase"),
("اپ پرشرز", "In-App Purchases"),
("اپل کانکت", "App Store Connect"),
("اپ استور کانکت", "App Store Connect"),
("حجزی مرچنت", "Hajzee Merchant"),
("حجزی یوزر", "Hajzee User"),
("حجزی", "Hajzee"),
("نیوهورایزن", "NewHorizon"),
("نیو هورایزن", "NewHorizon"),
("نیوهوریزن", "NewHorizon"),
("زر محک", "ZarMahak"),
("زرمحک", "ZarMahak"),
("سانی اوکس", "Soniox"),
("سانیوکس", "Soniox"),
("سانی اکس", "Soniox"),
("تیم بای", "TeamBy"),
("تیمبی", "TeamBy"),
("دوودی", "Dovodi"),
("داودی", "Dovodi"),
("داکر", "Docker"),
("کوبرنتیز", "Kubernetes"),
("کوبرنتس", "Kubernetes"),
("ای اس ایکس آی", "ESXi"),
("ای اس ایکس ای", "ESXi"),
("فست ای پی آی", "FastAPI"),
("فست ای پی ای", "FastAPI"),
("پستگرس کیو ال", "PostgreSQL"),
("پستگرس", "PostgreSQL"),
("ردیس", "Redis"),
("فلاتر", "Flutter"),
("وایارگارد", "WireGuard"),
("وایرگارد", "WireGuard"),
("میکروتیک", "MikroTik"),
("هرمس", "Hermes"),
("نکست جی اس", "Next.js"),
("ری اکت", "React"),
("پایتون", "Python"),
("سویفت", "Swift")
]
for (target, canonical) in terms {
for (target, canonical) in TextProcessor.domainTerms {
t = t.replacingOccurrences(of: target, with: canonical, options: .caseInsensitive) t = t.replacingOccurrences(of: target, with: canonical, options: .caseInsensitive)
} }
return t return t
@ -176,10 +188,8 @@ public final class TextProcessor {
return text return text
case .digits, .persianDigits: case .digits, .persianDigits:
// Convert spoken Persian numbers to digits
var t = " " + text + " " var t = " " + text + " "
// Compound tens and units
let compounds: [(String, String)] = [ let compounds: [(String, String)] = [
(" بیست و نه ", " 29 "), (" بیست و هشت ", " 28 "), (" بیست و هفت ", " 27 "), (" بیست و شش ", " 26 "), (" بیست و نه ", " 29 "), (" بیست و هشت ", " 28 "), (" بیست و هفت ", " 27 "), (" بیست و شش ", " 26 "),
(" بیست و پنج ", " 25 "), (" بیست و چهار ", " 24 "), (" بیست و سه ", " 23 "), (" بیست و دو ", " 22 "), (" بیست و یک ", " 21 "), (" بیست و پنج ", " 25 "), (" بیست و چهار ", " 24 "), (" بیست و سه ", " 23 "), (" بیست و دو ", " 22 "), (" بیست و یک ", " 21 "),
@ -203,7 +213,6 @@ public final class TextProcessor {
t = t.replacingOccurrences(of: word, with: num) t = t.replacingOccurrences(of: word, with: num)
} }
// Standard single numbers & round numbers
let simples: [(String, String)] = [ let simples: [(String, String)] = [
(" ده ", " 10 "), (" یازده ", " 11 "), (" دوازده ", " 12 "), (" سیزده ", " 13 "), (" چهارده ", " 14 "), (" ده ", " 10 "), (" یازده ", " 11 "), (" دوازده ", " 12 "), (" سیزده ", " 13 "), (" چهارده ", " 14 "),
(" پانزده ", " 15 "), (" شانزده ", " 16 "), (" هفده ", " 17 "), (" هجده ", " 18 "), (" نوزده ", " 19 "), (" پانزده ", " 15 "), (" شانزده ", " 16 "), (" هفده ", " 17 "), (" هجده ", " 18 "), (" نوزده ", " 19 "),
@ -245,20 +254,16 @@ public final class TextProcessor {
private func normalizeText(_ text: String) -> String { private func normalizeText(_ text: String) -> String {
var t = text var t = text
// Clean double spaces
while t.contains(" ") { while t.contains(" ") {
t = t.replacingOccurrences(of: " ", with: " ") t = t.replacingOccurrences(of: " ", with: " ")
} }
// Remove space before punctuation
let punctuationList = [".", "،", "؛", "؟", "!", ":", ")", "»"] let punctuationList = [".", "،", "؛", "؟", "!", ":", ")", "»"]
for p in punctuationList { for p in punctuationList {
t = t.replacingOccurrences(of: " " + p, with: p) t = t.replacingOccurrences(of: " " + p, with: p)
} }
// Ensure space after punctuation (unless end of line)
for p in [".", "،", "؛", "؟", "!"] { for p in [".", "،", "؛", "؟", "!"] {
t = t.replacingOccurrences(of: p, with: p + " ") t = t.replacingOccurrences(of: p, with: p + " ")
} }
// Common ASR stutter & merged word fixes
let speechFixes: [(String, String)] = [ let speechFixes: [(String, String)] = [
(" ماا ", " ما "), (" ماا ", " ما "),
(" اینپیگیری ", " این پیگیری "), (" اینپیگیری ", " این پیگیری "),
@ -270,7 +275,6 @@ public final class TextProcessor {
t = t.replacingOccurrences(of: bad, with: good) t = t.replacingOccurrences(of: bad, with: good)
} }
// Fix spaces inside parentheses / quotes
t = t.replacingOccurrences(of: "( ", with: "(") t = t.replacingOccurrences(of: "( ", with: "(")
t = t.replacingOccurrences(of: "« ", with: "«") t = t.replacingOccurrences(of: "« ", with: "«")
@ -280,20 +284,13 @@ public final class TextProcessor {
return t.trimmingCharacters(in: .whitespacesAndNewlines) return t.trimmingCharacters(in: .whitespacesAndNewlines)
} }
public func stripTrailingEndTrigger(_ text: String) -> String { public func stripTrailingEndTrigger(_ text: String) -> String {
var t = text.trimmingCharacters(in: .whitespacesAndNewlines) var t = text.trimmingCharacters(in: .whitespacesAndNewlines)
let pattern = #"[،,:\s]+(and|تمام|end)[.!?]?$"#
if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
let range = NSRange(location: 0, length: t.utf16.count)
t = regex.stringByReplacingMatches(in: t, options: [], range: range, withTemplate: "")
}
let singlePattern = #"^(and|تمام|end)[.!?]?$"#
if let regex = try? NSRegularExpression(pattern: singlePattern, options: .caseInsensitive) {
let range = NSRange(location: 0, length: t.utf16.count)
t = regex.stringByReplacingMatches(in: t, options: [], range: range, withTemplate: "")
}
let range1 = NSRange(location: 0, length: t.utf16.count)
t = TextProcessor.trailingEndRegex.stringByReplacingMatches(in: t, options: [], range: range1, withTemplate: "")
let range2 = NSRange(location: 0, length: t.utf16.count)
t = TextProcessor.singleEndRegex.stringByReplacingMatches(in: t, options: [], range: range2, withTemplate: "")
return t.trimmingCharacters(in: .whitespacesAndNewlines) return t.trimmingCharacters(in: .whitespacesAndNewlines)
} }
} }

13
src/VoiceCommandLogger.swift

@ -5,6 +5,7 @@ public final class VoiceCommandLogger {
public static let shared = VoiceCommandLogger() public static let shared = VoiceCommandLogger()
public let logFileURL: URL public let logFileURL: URL
private let maxLogSizeBytes: UInt64 = 5 * 1024 * 1024 // 5 MB max log size
private let queue = DispatchQueue(label: "com.soniox.voicecommandlogger", qos: .utility) private let queue = DispatchQueue(label: "com.soniox.voicecommandlogger", qos: .utility)
private let dateFormatter: DateFormatter private let dateFormatter: DateFormatter
@ -106,7 +107,18 @@ public final class VoiceCommandLogger {
} }
} }
private func rotateIfNeeded() {
if let attrs = try? FileManager.default.attributesOfItem(atPath: logFileURL.path),
let size = attrs[.size] as? UInt64, size > maxLogSizeBytes {
let backupURL = logFileURL.deletingLastPathComponent().appendingPathComponent("voice_commands.old.log")
try? FileManager.default.removeItem(at: backupURL)
try? FileManager.default.moveItem(at: logFileURL, to: backupURL)
ensureLogHeader()
}
}
private func appendToFile(text: String) { private func appendToFile(text: String) {
rotateIfNeeded()
guard let data = text.data(using: .utf8) else { return } guard let data = text.data(using: .utf8) else { return }
if let fileHandle = try? FileHandle(forWritingTo: logFileURL) { if let fileHandle = try? FileHandle(forWritingTo: logFileURL) {
fileHandle.seekToEndOfFile() fileHandle.seekToEndOfFile()
@ -133,7 +145,6 @@ public final class VoiceCommandLogger {
} }
public func openLogInViewer() { public func openLogInViewer() {
// Open log in default text editor or Console
NSWorkspace.shared.open(logFileURL) NSWorkspace.shared.open(logFileURL)
} }

Loading…
Cancel
Save