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. 824
      src/AppDelegate.swift
  4. 105
      src/AudioRecorder.swift
  5. 40
      src/HUDOverlay.swift
  6. 91
      src/HotkeyManager.swift
  7. 93
      src/InputBoxManager.swift
  8. 109
      src/PingMonitor.swift
  9. 451
      src/SonioxClient.swift
  10. 45
      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"

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

105
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 {
let session = captureSession
captureSession = nil
audioOutput = nil
audioConverter = nil
reusableSrcBuffer = nil
reusableOutBuffer = nil
lock.unlock()
if let session = session {
sessionQueue.async {
if session.isRunning { if session.isRunning {
session.stopRunning() session.stopRunning()
} }
} }
captureSession = nil
audioOutput = nil
audioConverter = nil
}
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()
if isRecording {
lock.unlock()
return
}
pcmBuffer.removeAll(keepingCapacity: true)
try ensureSessionConfigured() try ensureSessionConfigured()
if let session = captureSession, !session.isRunning {
let session = captureSession
isRecording = true
lock.unlock()
if let session = session {
sessionQueue.async {
if !session.isRunning {
session.startRunning() session.startRunning()
} }
isRecording = true
}
}
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

91
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.stateLock.lock()
let wasDown = manager.isF5PhysicallyDown
manager.isF5PhysicallyDown = true 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 {

93
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,8 +65,9 @@ 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 let lastRaw = triggerWords.last {
let last = InputBoxManager.stripPunctuation(lastRaw).lowercased()
if last == "and" || last == "تمام" || last == "end" {
if triggerWords.count <= 1 { if triggerWords.count <= 1 {
return // Drop completely if it was just the end word return // Drop completely if it was just the end word
} }
@ -63,13 +75,18 @@ public final class InputBoxManager {
streamDelta(text: textWithoutEnd) streamDelta(text: textWithoutEnd)
return 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()

451
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,36 +770,35 @@ 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 {
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 { break }
if done || isCancelled() { return }
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
if offset >= pcmData.count {
let silence = Data(count: 6400)
task.send(.data(silence)) { _ in
task.send(.string("{\"type\": \"finalize\"}")) { _ in }
}
return
} }
lock.lock()
let done = isDone
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 }
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
DispatchQueue.global().asyncAfter(deadline: .now() + watchdogTimeout) { DispatchQueue.global().asyncAfter(deadline: .now() + watchdogTimeout) {
lock.lock() lock.lock()
@ -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()
}
}
}
}

45
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,9 +132,7 @@ public final class TextProcessor {
return t return t
} }
private func applyDomainVocabulary(_ text: String) -> String {
var t = text
let terms: [(String, String)] = [
private static let domainTerms: [(String, String)] = [
("این اپ پرچس", "In-App Purchase"), ("این اپ پرچس", "In-App Purchase"),
("این اپ پرشرز", "In-App Purchase"), ("این اپ پرشرز", "In-App Purchase"),
("اپ پرشرز", "In-App Purchases"), ("اپ پرشرز", "In-App Purchases"),
@ -164,7 +174,9 @@ public final class TextProcessor {
("سویفت", "Swift") ("سویفت", "Swift")
] ]
for (target, canonical) in terms {
private func applyDomainVocabulary(_ text: String) -> String {
var t = text
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