20 Commits

Author SHA1 Message Date
Ali Alavi f41df96ce7 feat: add AppIntents support, disable AppNap, and export physical key status 5 days ago
Ali Alavi 1602bb5f02 fix: strictly restrict live streaming to Soniox AI-edited finalized tokens (is_final == true) 6 days ago
Ali Alavi e12e4a08e7 fix: remove paste suppression rule and add fuzzy word matching to guarantee 100% of unstreamed words are pasted without dropping 6 days ago
Ali Alavi 624199910b fix: restore live streaming of stable completed words as user speaks 6 days ago
Ali Alavi 488a2a46d2 fix: resolve fast speech truncation by flushing hardware PCM buffer and extending finalize timeout to 4.5s 6 days ago
Ali Alavi bc878a3341 fix: guarantee 100% audio conversion via Smart Recovery on all drops/empty responses and show clean English error state in HUD Orb 6 days ago
Ali Alavi efd86a1307 fix: extend post-release grace period to 1.0s and optimize finalize timeout to dismiss HUD orb instantly 6 days ago
Ali Alavi bf2c5bacee feat: stream ONLY Soniox-finalized (is_final == true) chunks live into input box 6 days ago
Ali Alavi b36a734b00 fix: restore ultra-fast real-time word-by-word streaming while speaking while keeping anti-duplication guards 6 days ago
Ali Alavi c3bce16df2 fix: stream ONLY finalized (is_final == true) tokens live as user speaks, eliminating raw acoustic noise and double-pasting 6 days ago
Ali Alavi 1fd339334a fix: disable live unfinalized delta pasting into input box to prevent garbled text and duplicate pasting 6 days ago
Ali Alavi d6c07d036c fix: enforce single-instance check with NSRunningApplication and lockfile guard 6 days ago
Ali Alavi 8693939c0d refactor: completely purge custom wake-word engine and dylibs; rely exclusively on Siri and Push-to-Talk 1 week ago
Ali Alavi f6d93db381 feat: ultra-low latency real-time word-by-word streaming injection with in-flight end trigger detection 1 week ago
Ali Alavi 98fe53a47d fix: revert start sound to Tink and eliminate 10s silence recovery freeze on Option tap 1 week ago
Ali Alavi a44fc57404 feat: add applicationShouldHandleReopen and soniox://start URL scheme for instant Siri Shortcuts integration 1 week ago
Ali Alavi 965307be77 fix: loud Hero activation chime with afplay fallback 1 week ago
Ali Alavi 07f8ff2b41 feat: native multi-keyword wake word engine (Jarvis, Computer, Alexa) with Sherpa-ONNX Zipformer 1 week ago
Ali Alavi e2ef304d69 feat: add Darwin notification triggers, in-stream end trigger ('and'/'تمام'), and wake/end word sanitization 1 week ago
Ali Alavi a0337df9e6 fix: resilient multi-stage audio retry, mid-stream disconnect recovery, and intelligent token stitching 2 weeks ago
  1. 4
      .gitignore
  2. 13
      Info.plist
  3. 45
      package.sh
  4. BIN
      resources/AppIcon.icns
  5. 1
      resources/Metadata.appintents/extract.actionsdata
  6. 4
      resources/Metadata.appintents/version.json
  7. 409
      src/AppDelegate.swift
  8. 21
      src/AudioRecorder.swift
  9. 46
      src/HUDOverlay.swift
  10. 4
      src/HotkeyManager.swift
  11. 153
      src/InputBoxManager.swift
  12. 85
      src/Intents.swift
  13. 270
      src/SonioxClient.swift
  14. 10
      src/SonioxSettings.swift
  15. 5
      src/StatusBarController.swift
  16. 20
      src/TextProcessor.swift
  17. 24
      src/VoiceCommandLogger.swift
  18. 33
      src/main.swift

4
.gitignore

@ -0,0 +1,4 @@
build/
*.dmg
*.zip
.DS_Store

13
Info.plist

@ -26,6 +26,8 @@
<string>13.0</string> <string>13.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSSupportsAppNap</key>
<false/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>
<true/> <true/>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
@ -37,5 +39,16 @@
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true/>
</dict> </dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.soniox.voice</string>
<key>CFBundleURLSchemes</key>
<array>
<string>soniox</string>
</array>
</dict>
</array>
</dict> </dict>
</plist> </plist>

45
package.sh

@ -1,20 +1,19 @@
#!/bin/zsh
#!/bin/bash
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="${PROJECT_DIR:-$SCRIPT_DIR}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$SCRIPT_DIR"
BUILD_DIR="$PROJECT_DIR/build" BUILD_DIR="$PROJECT_DIR/build"
APP_NAME="Soniox Voice" APP_NAME="Soniox Voice"
APP_BUNDLE="$BUILD_DIR/$APP_NAME.app" APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
DMG_NAME="SonioxVoice-v1.0.0.dmg"
echo "🔨 Building $APP_NAME..." echo "🔨 Building $APP_NAME..."
mkdir -p "$BUILD_DIR" mkdir -p "$BUILD_DIR"
rm -rf "$APP_BUNDLE" rm -rf "$APP_BUNDLE"
# 1. Compile Swift sources
# 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 Cocoa -framework AVFoundation -framework Carbon -framework AppIntents \
"$PROJECT_DIR"/src/*.swift \ "$PROJECT_DIR"/src/*.swift \
-o "$BUILD_DIR/SonioxVoice" -o "$BUILD_DIR/SonioxVoice"
@ -30,31 +29,41 @@ cp "$BUILD_DIR/SonioxVoice" "$APP_BUNDLE/Contents/MacOS/SonioxVoice"
cp "$PROJECT_DIR/Info.plist" "$APP_BUNDLE/Contents/Info.plist" cp "$PROJECT_DIR/Info.plist" "$APP_BUNDLE/Contents/Info.plist"
cp "$PROJECT_DIR/resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/AppIcon.icns" cp "$PROJECT_DIR/resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/AppIcon.icns"
if [ -d "$PROJECT_DIR/resources/Metadata.appintents" ]; then
cp -R "$PROJECT_DIR/resources/Metadata.appintents" "$APP_BUNDLE/Contents/Resources/"
fi
# 3. Ad-hoc Codesign # 3. Ad-hoc Codesign
codesign --force --deep --sign - "$APP_BUNDLE" codesign --force --deep --sign - "$APP_BUNDLE"
echo "✅ App bundle assembled at $APP_BUNDLE" echo "✅ App bundle assembled at $APP_BUNDLE"
# 4. Create DMG Installer
# 4. Create DMG
DMG_NAME="SonioxVoice-v1.0.0.dmg"
DMG_PATH="$BUILD_DIR/$DMG_NAME"
rm -f "$DMG_PATH"
echo "📦 Creating DMG Installer..." echo "📦 Creating DMG Installer..."
DMG_STAGING="$BUILD_DIR/dmg_staging"
rm -rf "$DMG_STAGING" "$BUILD_DIR/$DMG_NAME"
mkdir -p "$DMG_STAGING"
STAGING_DIR="$BUILD_DIR/dmg_staging"
rm -rf "$STAGING_DIR"
mkdir -p "$STAGING_DIR"
cp -R "$APP_BUNDLE" "$STAGING_DIR/"
ln -s /Applications "$STAGING_DIR/Applications"
cp -R "$APP_BUNDLE" "$DMG_STAGING/"
ln -s /Applications "$DMG_STAGING/Applications"
hdiutil create -volname "SonioxVoice" -srcfolder "$STAGING_DIR" -ov -format UDZO "$DMG_PATH"
rm -rf "$STAGING_DIR"
hdiutil create -volname "Soniox Voice" -srcfolder "$DMG_STAGING" -ov -format UDZO "$BUILD_DIR/$DMG_NAME"
echo "✅ DMG created at $BUILD_DIR/$DMG_NAME"
echo "✅ DMG created at $DMG_PATH"
# 5. Create ZIP Archive
# 5. Create ZIP for release
cd "$BUILD_DIR" cd "$BUILD_DIR"
zip -r -y "SonioxVoice-v1.0.0.zip" "$APP_NAME.app"
zip -r -y "SonioxVoice-v1.0.0.zip" "$APP_NAME.app" > /dev/null
cd "$PROJECT_DIR"
# 6. Install to /Applications on Mac
# 6. Install to /Applications
echo "🚀 Installing to /Applications/$APP_NAME.app..." echo "🚀 Installing to /Applications/$APP_NAME.app..."
rm -rf "/Applications/$APP_NAME.app" rm -rf "/Applications/$APP_NAME.app"
cp -R "$APP_BUNDLE" "/Applications/$APP_NAME.app"
cp -R "$APP_BUNDLE" "/Applications/"
echo "🎉 All Done Successfully!" echo "🎉 All Done Successfully!"
ls -lh "$BUILD_DIR" ls -lh "$BUILD_DIR"

BIN
resources/AppIcon.icns

1
resources/Metadata.appintents/extract.actionsdata

@ -0,0 +1 @@
{"actions": {"StartRecordingIntent": {"actionConfiguration": {"actionSummary": {"wrapper": {"otherParameterIdentifiers": []}}}, "assistantDefinedSchemaTraits": [], "assistantDefinedSchemas": [], "authenticationPolicy": 0, "availabilityAnnotations": {"LNPlatformNameIOS": {"introducedVersion": "16.0"}, "LNPlatformNameMACOS": {"introducedVersion": "13.0"}, "LNPlatformNameWildcard": {"introducedVersion": "*"}}, "customIntentClassName": "StartRecordingIntent", "descriptionMetadata": {"descriptionText": {"alternatives": [], "key": "Starts voice recording and real-time word-by-word streaming."}, "searchKeywords": []}, "effectiveBundleIdentifiers": [], "fullyQualifiedTypeName": "SonioxVoice.StartRecordingIntent", "identifier": "StartRecordingIntent", "isAuthPolExplicit": false, "isDiscoverable": true, "mangledTypeName": "11SonioxVoice20StartRecordingIntentV", "mangledTypeNameByBundleIdentifier": {}, "mangledTypeNameByBundleIdentifierV2": {}, "mangledTypeNameV2": "11SonioxVoice20StartRecordingIntentV", "openAppWhenRun": false, "outputFlags": 0, "parameters": [], "presentationStyle": 0, "requiredCapabilities": [], "supportedModes": 1, "systemProtocolMetadata": [], "systemProtocolMetadataV2": [], "systemProtocols": [], "title": {"alternatives": [], "key": "Start Recording"}, "typeSpecificMetadata": [], "visibilityMetadata": {"assistantOnly": false, "isDiscoverable": true}}, "StopRecordingIntent": {"actionConfiguration": {"actionSummary": {"wrapper": {"otherParameterIdentifiers": []}}}, "assistantDefinedSchemaTraits": [], "assistantDefinedSchemas": [], "authenticationPolicy": 0, "availabilityAnnotations": {"LNPlatformNameIOS": {"introducedVersion": "16.0"}, "LNPlatformNameMACOS": {"introducedVersion": "13.0"}, "LNPlatformNameWildcard": {"introducedVersion": "*"}}, "customIntentClassName": "StopRecordingIntent", "descriptionMetadata": {"descriptionText": {"alternatives": [], "key": "Stops active recording and finalizes text paste."}, "searchKeywords": []}, "effectiveBundleIdentifiers": [], "fullyQualifiedTypeName": "SonioxVoice.StopRecordingIntent", "identifier": "StopRecordingIntent", "isAuthPolExplicit": false, "isDiscoverable": true, "mangledTypeName": "11SonioxVoice19StopRecordingIntentV", "mangledTypeNameByBundleIdentifier": {}, "mangledTypeNameByBundleIdentifierV2": {}, "mangledTypeNameV2": "11SonioxVoice19StopRecordingIntentV", "openAppWhenRun": false, "outputFlags": 0, "parameters": [], "presentationStyle": 0, "requiredCapabilities": [], "supportedModes": 1, "systemProtocolMetadata": [], "systemProtocolMetadataV2": [], "systemProtocols": [], "title": {"alternatives": [], "key": "Stop Recording"}, "typeSpecificMetadata": [], "visibilityMetadata": {"assistantOnly": false, "isDiscoverable": true}}, "ToggleRecordingIntent": {"actionConfiguration": {"actionSummary": {"wrapper": {"otherParameterIdentifiers": []}}}, "assistantDefinedSchemaTraits": [], "assistantDefinedSchemas": [], "authenticationPolicy": 0, "availabilityAnnotations": {"LNPlatformNameIOS": {"introducedVersion": "16.0"}, "LNPlatformNameMACOS": {"introducedVersion": "13.0"}, "LNPlatformNameWildcard": {"introducedVersion": "*"}}, "customIntentClassName": "ToggleRecordingIntent", "descriptionMetadata": {"descriptionText": {"alternatives": [], "key": "Toggles voice recording on or off."}, "searchKeywords": []}, "effectiveBundleIdentifiers": [], "fullyQualifiedTypeName": "SonioxVoice.ToggleRecordingIntent", "identifier": "ToggleRecordingIntent", "isAuthPolExplicit": false, "isDiscoverable": true, "mangledTypeName": "11SonioxVoice21ToggleRecordingIntentV", "mangledTypeNameByBundleIdentifier": {}, "mangledTypeNameByBundleIdentifierV2": {}, "mangledTypeNameV2": "11SonioxVoice21ToggleRecordingIntentV", "openAppWhenRun": false, "outputFlags": 0, "parameters": [], "presentationStyle": 0, "requiredCapabilities": [], "supportedModes": 1, "systemProtocolMetadata": [], "systemProtocolMetadataV2": [], "systemProtocols": [], "title": {"alternatives": [], "key": "Toggle Recording"}, "typeSpecificMetadata": [], "visibilityMetadata": {"assistantOnly": false, "isDiscoverable": true}}}, "assistantEntities": [], "assistantIntentNegativePhrases": [], "assistantIntents": [], "autoShortcutProviderMangledName": "", "autoShortcuts": [], "entities": {}, "enums": [], "generator": {"name": "xcode-tools", "version": "17C52"}, "negativePhrases": [], "queries": {}, "shortcutTileColor": 14, "version": 1}

4
resources/Metadata.appintents/version.json

@ -0,0 +1,4 @@
{
"version" : "3.0",
"toolsVersion" : "17C52"
}

409
src/AppDelegate.swift

@ -3,20 +3,53 @@ import AVFoundation
import ApplicationServices import ApplicationServices
public final class AppDelegate: NSObject, NSApplicationDelegate { public final class AppDelegate: NSObject, NSApplicationDelegate {
public static private(set) var shared: AppDelegate?
public override init() {
super.init()
AppDelegate.shared = self
}
private var statusBarController: StatusBarController! private var statusBarController: StatusBarController!
private var audioRecorder = AudioRecorder() private var audioRecorder = AudioRecorder()
private var activeSession: SonioxLiveSession? private var activeSession: SonioxLiveSession?
private var isBusyFinalizing = false private var isBusyFinalizing = false
private var currentAudioLevel: Float = 0.0 private var currentAudioLevel: Float = 0.0
private var handsFreeSilenceTimer: Timer?
private var lastVoiceDetectedTime: Date = Date()
private var recordingStartTime: Date = Date()
private var hasSpokenInCurrentSession: Bool = false
private var pendingStopWorkItem: DispatchWorkItem? = nil private var pendingStopWorkItem: DispatchWorkItem? = nil
private var isNetworkDegraded: Bool = false private var isNetworkDegraded: Bool = false
private var lastRecordedAudio: Data = Data() private var lastRecordedAudio: Data = Data()
private var finalizeWatchdogWorkItem: DispatchWorkItem? = nil
public func applicationDidFinishLaunching(_ notification: Notification) { public func applicationDidFinishLaunching(_ notification: Notification) {
setbuf(stdout, nil)
setbuf(stderr, nil)
if UserDefaults.standard.object(forKey: "SonioxPlaySounds") == nil { if UserDefaults.standard.object(forKey: "SonioxPlaySounds") == nil {
UserDefaults.standard.set(true, forKey: "SonioxPlaySounds") UserDefaults.standard.set(true, forKey: "SonioxPlaySounds")
} }
// Setup DistributedNotificationCenter observers for Shortcuts AppIntents & external triggers
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(handleDistributedStartRecording),
name: NSNotification.Name("com.soniox.voice.start"),
object: nil
)
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(handleDistributedStopRecording),
name: NSNotification.Name("com.soniox.voice.stop"),
object: nil
)
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(handleDistributedToggleRecording),
name: NSNotification.Name("com.soniox.voice.toggle"),
object: nil
)
statusBarController = StatusBarController() statusBarController = StatusBarController()
statusBarController.onToggleRecording = { [weak self] in statusBarController.onToggleRecording = { [weak self] in
self?.toggleRecording() self?.toggleRecording()
@ -48,8 +81,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
self.pendingStopWorkItem = workItem self.pendingStopWorkItem = workItem
// Trailing grace period: 0.5s (500ms) buffer so trailing words/syllables are never clipped
DispatchQueue.main.asyncAfter(deadline: .now() + 0.50, execute: workItem)
// Trailing grace period: 0.35s (350ms) buffer after key release so trailing fast speech is never clipped
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: workItem)
} }
} }
} }
@ -64,6 +97,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
audioRecorder.onAudioLevelUpdate = { [weak self] level in audioRecorder.onAudioLevelUpdate = { [weak self] level in
guard let self = self else { return } guard let self = self else { return }
self.currentAudioLevel = level self.currentAudioLevel = level
if level > 0.07 {
self.hasSpokenInCurrentSession = true
self.lastVoiceDetectedTime = Date()
}
if self.audioRecorder.isRecording { if self.audioRecorder.isRecording {
HUDOverlayController.shared.show(state: .recording(level: level, isLagging: self.isNetworkDegraded, pingQuality: PingMonitor.shared.currentQuality)) HUDOverlayController.shared.show(state: .recording(level: level, isLagging: self.isNetworkDegraded, pingQuality: PingMonitor.shared.currentQuality))
} }
@ -91,6 +128,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
// Check permissions on start // Check permissions on start
checkInitialPermissions() checkInitialPermissions()
setupDarwinNotifications()
} }
private func checkInitialPermissions() { private func checkInitialPermissions() {
@ -109,8 +147,19 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
} }
private func playSystemSound(name: String) { private func playSystemSound(name: String) {
if SonioxSettings.shared.playSounds {
NSSound(named: NSSound.Name(name))?.play()
guard SonioxSettings.shared.playSounds else { return }
DispatchQueue.main.async {
if let sound = NSSound(named: NSSound.Name(name)) {
sound.volume = 1.0
sound.play()
} else {
DispatchQueue.global(qos: .userInitiated).async {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/afplay")
p.arguments = ["/System/Library/Sounds/\(name).aiff"]
try? p.run()
}
}
} }
} }
@ -124,6 +173,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
public func startRecording() { public func startRecording() {
if audioRecorder.isRecording { return } if audioRecorder.isRecording { return }
finalizeWatchdogWorkItem?.cancel()
finalizeWatchdogWorkItem = nil
if isBusyFinalizing { if isBusyFinalizing {
print("AppDelegate: isBusyFinalizing was active when startRecording invoked. Clearing stale lock.") print("AppDelegate: isBusyFinalizing was active when startRecording invoked. Clearing stale lock.")
isBusyFinalizing = false isBusyFinalizing = false
@ -131,12 +182,22 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
isNetworkDegraded = false isNetworkDegraded = false
lastRecordedAudio.removeAll() lastRecordedAudio.removeAll()
recordingStartTime = Date()
lastVoiceDetectedTime = Date()
hasSpokenInCurrentSession = false
// Start Hands-free silence watchdog timer (checks every 250ms)
handsFreeSilenceTimer?.invalidate()
handsFreeSilenceTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
self?.checkHandsFreeSilence()
}
// 1. Ensure target input box is focused without stealing user cursor // 1. Ensure target input box is focused without stealing user cursor
InputBoxManager.shared.ensureFocusedInputBox() InputBoxManager.shared.ensureFocusedInputBox()
// 2. Acquire pre-warmed / fast-connected WebSocket session // 2. Acquire pre-warmed / fast-connected WebSocket session
let session = SonioxSessionPool.shared.acquireSession() let session = SonioxSessionPool.shared.acquireSession()
session.setRecordingActive(true)
self.activeSession = session self.activeSession = session
// Handle network degradation warning // Handle network degradation warning
@ -149,7 +210,24 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
} }
// Stream live finalized tokens directly into input box word-by-word // Stream live finalized tokens directly into input box word-by-word
session.onCommittedDelta = { delta in
session.onCommittedDelta = { [weak self] delta in
guard let self = self else { return }
// Check for in-stream end trigger ("and", "تمام", "end")
let deltaWords = delta.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
if let lastWord = deltaWords.last?.replacingOccurrences(of: #"[.,!?:;،؛؟"'()\[\]{}«»–—\-]"#, with: "", options: .regularExpression).lowercased() {
if lastWord == "and" || lastWord == "تمام" || lastWord == "end" {
print("AppDelegate: In-speech end trigger detected ('\(lastWord)'). Finalizing speech...")
DispatchQueue.main.async {
self.stopRecording()
}
return
}
}
self.hasSpokenInCurrentSession = true
self.lastVoiceDetectedTime = Date()
// If it's an executive slash command, do NOT type it into the input box // If it's an executive slash command, do NOT type it into the input box
if SonioxSettings.shared.voiceCommands && VoiceCommandExecutor.shared.isSlashCommand(text: delta) { if SonioxSettings.shared.voiceCommands && VoiceCommandExecutor.shared.isSlashCommand(text: delta) {
return return
@ -157,9 +235,29 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
InputBoxManager.shared.streamDelta(text: delta) InputBoxManager.shared.streamDelta(text: delta)
} }
session.onEndTriggerDetected = { [weak self] in
guard let self = self else { return }
print("AppDelegate: In-flight end trigger ('and'/'تمام') detected. Stopping recording immediately...")
DispatchQueue.main.async {
self.stopRecording()
}
}
session.onFinalResult = { [weak self] result in session.onFinalResult = { [weak self] result in
DispatchQueue.main.async { DispatchQueue.main.async {
guard let self = self else { return } guard let self = self else { return }
if self.audioRecorder.isRecording {
if HotkeyManager.shared.isPhysicalKeyDown {
print("AppDelegate: Session finished/failed while user is physically holding hotkey. Marking degraded; recovery will run upon release.")
self.isNetworkDegraded = true
HUDOverlayController.shared.show(state: .recording(level: self.currentAudioLevel, isLagging: true))
return
} else {
print("AppDelegate: Session finished in hands-free/Siri mode. Stopping recording and finalizing.")
self.stopRecording()
return
}
}
self.handleFinalResult(result) self.handleFinalResult(result)
} }
} }
@ -174,12 +272,13 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
print("Failed to start audio recording:", error) print("Failed to start audio recording:", error)
activeSession?.cancel() activeSession?.cancel()
activeSession = nil activeSession = nil
HUDOverlayController.shared.show(state: .error)
HUDOverlayController.shared.show(state: .error(message: "REC ERROR"))
} }
} }
private func hasAudibleSpeech(pcmData: Data) -> Bool { private func hasAudibleSpeech(pcmData: Data) -> Bool {
guard pcmData.count >= 3200 else { return false }
// Less than 0.25s of audio is just a momentary key tap/click
guard pcmData.count >= 8000 else { return false }
var sumSquares: Double = 0 var sumSquares: Double = 0
var peak: Int16 = 0 var peak: Int16 = 0
let count = pcmData.count / 2 let count = pcmData.count / 2
@ -194,20 +293,24 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
let rms = sqrt(sumSquares / Double(count)) let rms = sqrt(sumSquares / Double(count))
return peak > 1500 && rms > 0.010
// Raised thresholds: filter out mechanical keyclicks and keyboard taps
return peak > 2500 && rms > 0.015
} }
public func stopRecording() { public func stopRecording() {
handsFreeSilenceTimer?.invalidate()
handsFreeSilenceTimer = nil
HotkeyManager.shared.resetKeyState() HotkeyManager.shared.resetKeyState()
guard audioRecorder.isRecording else { return } guard audioRecorder.isRecording else { return }
activeSession?.setRecordingActive(false)
let pcmData = audioRecorder.stopRecording() let pcmData = audioRecorder.stopRecording()
self.lastRecordedAudio = pcmData self.lastRecordedAudio = pcmData
// Fast-path: If user did NOT speak (silence / short ambient hold), dismiss immediately with ZERO delay!
// Fast-path: If user tapped key or did NOT speak (silence / keyclick), dismiss immediately with ZERO delay!
let hasSpeech = hasAudibleSpeech(pcmData: pcmData) let hasSpeech = hasAudibleSpeech(pcmData: pcmData)
if !hasSpeech && !InputBoxManager.shared.hasStreamedText {
print("AppDelegate: No audible speech detected. Immediate dismissal without freeze.")
if (!hasSpeech && !InputBoxManager.shared.hasStreamedText) || pcmData.count < 8000 {
print("AppDelegate: Short tap or silence detected. Immediate 0ms dismissal.")
activeSession?.cancel() activeSession?.cancel()
activeSession = nil activeSession = nil
isBusyFinalizing = false isBusyFinalizing = false
@ -220,24 +323,39 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
playSystemSound(name: "Pop") playSystemSound(name: "Pop")
statusBarController.updateIcon(state: .transcribing) statusBarController.updateIcon(state: .transcribing)
statusBarController.buildMenu(isRecording: false) statusBarController.buildMenu(isRecording: false)
HUDOverlayController.shared.show(state: .transcribing)
isBusyFinalizing = true isBusyFinalizing = true
// Safety timeout watchdog: finalize must complete within 2.5s
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in
// Check if the live session suffered a network failure during recording
let sessionDropped = (self.activeSession == nil) || (self.activeSession?.hasNetworkFailure == true) || self.isNetworkDegraded
if sessionDropped {
print("AppDelegate: Live session dropped/degraded during recording. Triggering Smart Audio Recovery immediately.")
startSmartAudioRecovery(pcmData: pcmData, reason: "Live session dropped mid-recording")
return
}
HUDOverlayController.shared.show(state: .transcribing)
// Dynamic safety timeout watchdog: scale with audio length & network transit
let durationSec = Double(pcmData.count) / 32000.0
let finalizeTimeout = max(4.0, min(12.0, durationSec * 0.25 + 3.5))
let watchdog = DispatchWorkItem { [weak self] in
guard let self = self, self.isBusyFinalizing else { return } guard let self = self, self.isBusyFinalizing else { return }
print("AppDelegate: Finalize safety watchdog triggered — recovering transcript.")
self.isBusyFinalizing = false
self.statusBarController.updateIcon(state: .idle)
HUDOverlayController.shared.hide()
self.activeSession?.completeWithCurrentText()
print("AppDelegate: Finalize safety watchdog triggered (\(String(format: "%.1f", finalizeTimeout))s) — starting Smart Audio Recovery.")
self.finalizeWatchdogWorkItem = nil
self.startSmartAudioRecovery(pcmData: self.lastRecordedAudio, reason: "TIMEOUT")
} }
self.finalizeWatchdogWorkItem = watchdog
DispatchQueue.main.asyncAfter(deadline: .now() + finalizeTimeout, execute: watchdog)
activeSession?.finalizeStream() activeSession?.finalizeStream()
} }
private func handleFinalResult(_ result: Result<String, Error>) { private func handleFinalResult(_ result: Result<String, Error>) {
finalizeWatchdogWorkItem?.cancel()
finalizeWatchdogWorkItem = nil
guard isBusyFinalizing else { return }
self.activeSession = nil self.activeSession = nil
self.statusBarController.updateIcon(state: .idle) self.statusBarController.updateIcon(state: .idle)
@ -253,37 +371,93 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
let hasSpeech = hasAudibleSpeech(pcmData: self.lastRecordedAudio) let hasSpeech = hasAudibleSpeech(pcmData: self.lastRecordedAudio)
// Check if smart audio recovery is needed:
// STRICT: Only retry if there was an ACTUAL network failure/drop AND audible speech was recorded.
// NEVER retry when Soniox cleanly returned empty text for silence!
let needsRetry = (isNetworkError || self.isNetworkDegraded) && hasSpeech && self.lastRecordedAudio.count > 16000
// 1. Primary Success Path: Non-empty transcript returned
if !rawText.isEmpty {
self.isBusyFinalizing = false
deliverOutputText(rawText)
return
}
// 2. Resilient Recovery Path: If user actually spoke but server returned empty/error/dropped
if hasSpeech && self.lastRecordedAudio.count >= 8000 {
let reason = isNetworkError ? "NET ERROR" : "RETRYING..."
print("AppDelegate: Primary STT returned empty/error for audible speech. Triggering Smart Audio Recovery...")
startSmartAudioRecovery(pcmData: self.lastRecordedAudio, reason: reason)
return
}
if needsRetry {
print("AppDelegate: Network drop detected with audible voice. Triggering Smart Audio Recovery with \(self.lastRecordedAudio.count) bytes...")
HUDOverlayController.shared.show(state: .retrying)
let audioCopy = self.lastRecordedAudio
let hints = SonioxSettings.shared.language.hints
let target = SonioxSettings.shared.targetLanguage.isEnabled ? SonioxSettings.shared.targetLanguage.rawValue : nil
// 3. Strict Silence / Key-tap Gating: Pure silence or tap
self.isBusyFinalizing = false
HUDOverlayController.shared.hide()
}
private func startSmartAudioRecovery(pcmData: Data, reason: String) {
finalizeWatchdogWorkItem?.cancel()
finalizeWatchdogWorkItem = nil
guard !pcmData.isEmpty else {
self.isBusyFinalizing = false
self.statusBarController.updateIcon(state: .idle)
HUDOverlayController.shared.hide()
return
}
let durationSec = Double(pcmData.count) / 32000.0
print("AppDelegate: Starting Smart Audio Recovery [\(reason)] with \(pcmData.count) bytes (\(String(format: "%.1f", durationSec))s)...")
// Show glowing amber recovery state on HUD with English reason
let hudReason = reason.contains("TIMEOUT") ? "TIMEOUT" : "RETRYING..."
HUDOverlayController.shared.show(state: .retrying(reason: hudReason))
statusBarController.updateIcon(state: .transcribing)
// Cancel active session to prevent resource leaks
self.activeSession?.cancel()
self.activeSession = nil
// Concurrently prime a fresh session pool in background for the NEXT speech
SonioxSessionPool.shared.reconnectAll()
let hints = SonioxSettings.shared.language.hints
let target = SonioxSettings.shared.targetLanguage.isEnabled ? SonioxSettings.shared.targetLanguage.rawValue : nil
SonioxLiveSession.retryFullBufferedAudio(pcmData: pcmData, languageHints: hints, targetLanguage: target, onProgress: nil) { [weak self] retryResult in
guard let self = self else { return }
self.isBusyFinalizing = false
self.statusBarController.updateIcon(state: .idle)
SonioxLiveSession.retryFullBufferedAudio(pcmData: audioCopy, languageHints: hints, targetLanguage: target, onProgress: nil) { [weak self] retryResult in
guard let self = self else { return }
self.isBusyFinalizing = false
switch retryResult {
case .success(let recoveredText):
let trimmed = recoveredText.trimmingCharacters(in: .whitespacesAndNewlines)
print("AppDelegate: Smart Audio Recovery SUCCEEDED! Recovered \(trimmed.count) chars.")
VoiceCommandLogger.shared.logRecovery(
audioBytes: pcmData.count,
durationSec: durationSec,
reason: reason,
outcome: "SUCCESS",
recoveredText: trimmed
)
self.deliverOutputText(trimmed)
switch retryResult {
case .success(let recoveredText):
let trimmed = recoveredText.trimmingCharacters(in: .whitespacesAndNewlines)
let finalText = trimmed.isEmpty ? rawText : trimmed
self.deliverOutputText(finalText)
case .failure:
self.deliverOutputText(rawText)
case .failure(let error):
print("AppDelegate: Smart Audio Recovery failed completely: \(error)")
VoiceCommandLogger.shared.logRecovery(
audioBytes: pcmData.count,
durationSec: durationSec,
reason: reason,
outcome: "FAILED: \(error.localizedDescription)",
recoveredText: ""
)
let errMsg: String
let errStr = error.localizedDescription.lowercased()
if errStr.contains("timed out") || errStr.contains("timeout") {
errMsg = "TIMEOUT"
} else if errStr.contains("network") || errStr.contains("connection") || errStr.contains("offline") {
errMsg = "NET ERROR"
} else {
errMsg = "STT ERROR"
} }
HUDOverlayController.shared.show(state: .error(message: errMsg))
} }
return
} }
self.isBusyFinalizing = false
deliverOutputText(rawText)
} }
private func deliverOutputText(_ text: String) { private func deliverOutputText(_ text: String) {
@ -311,7 +485,154 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
public func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
print("AppDelegate: Application reopen triggered (Siri / Shortcut).")
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.audioRecorder.isRecording {
print("AppDelegate: Already recording on reopen — stopping and finalizing.")
self.stopRecording()
} else {
self.startRecording()
}
}
return true
}
public func application(_ application: NSApplication, open urls: [URL]) {
for url in urls {
if url.scheme == "soniox" {
let action = url.host ?? url.path
if action.contains("start") {
DispatchQueue.main.async { [weak self] in
self?.startRecording()
}
} else if action.contains("stop") {
DispatchQueue.main.async { [weak self] in
self?.stopRecording()
}
} else if action.contains("toggle") {
DispatchQueue.main.async { [weak self] in
self?.toggleRecording()
}
}
}
}
}
public func applicationWillTerminate(_ notification: Notification) { public func applicationWillTerminate(_ notification: Notification) {
HotkeyManager.shared.unregisterHotkeys() HotkeyManager.shared.unregisterHotkeys()
} }
private func setupDarwinNotifications() {
let notifyCenter = CFNotificationCenterGetDarwinNotifyCenter()
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
let startCb: CFNotificationCallback = { (_, observer, _, _, _) in
guard let observer = observer else { return }
let app = Unmanaged<AppDelegate>.fromOpaque(observer).takeUnretainedValue()
DispatchQueue.main.async {
if !app.audioRecorder.isRecording {
print("AppDelegate: Received Darwin notification 'com.soniox.voice.start'. Starting recording...")
app.startRecording()
}
}
}
let stopCb: CFNotificationCallback = { (_, observer, _, _, _) in
guard let observer = observer else { return }
let app = Unmanaged<AppDelegate>.fromOpaque(observer).takeUnretainedValue()
DispatchQueue.main.async {
if app.audioRecorder.isRecording {
print("AppDelegate: Received Darwin notification 'com.soniox.voice.stop'. Stopping recording...")
app.stopRecording()
}
}
}
let toggleCb: CFNotificationCallback = { (_, observer, _, _, _) in
guard let observer = observer else { return }
let app = Unmanaged<AppDelegate>.fromOpaque(observer).takeUnretainedValue()
DispatchQueue.main.async {
print("AppDelegate: Received Darwin notification 'com.soniox.voice.toggle'.")
app.toggleRecording()
}
}
CFNotificationCenterAddObserver(notifyCenter, selfPtr, startCb, "com.soniox.voice.start" as CFString, nil, .deliverImmediately)
CFNotificationCenterAddObserver(notifyCenter, selfPtr, stopCb, "com.soniox.voice.stop" as CFString, nil, .deliverImmediately)
CFNotificationCenterAddObserver(notifyCenter, selfPtr, toggleCb, "com.soniox.voice.toggle" as CFString, nil, .deliverImmediately)
}
private func checkHandsFreeSilence() {
guard audioRecorder.isRecording else {
handsFreeSilenceTimer?.invalidate()
handsFreeSilenceTimer = nil
return
}
// If user is physically holding down a hotkey, do NOT auto-stop
if HotkeyManager.shared.isPhysicalKeyDown {
return
}
let now = Date()
let elapsedSinceStart = now.timeIntervalSince(recordingStartTime)
// 1. Initial silence timeout: if no speech detected for 5.0s after start, auto-dismiss
if !hasSpokenInCurrentSession && !InputBoxManager.shared.hasStreamedText {
if elapsedSinceStart >= 5.0 {
print("AppDelegate: Hands-free initial silence timeout (5.0s). Auto-dismissing.")
DispatchQueue.main.async { [weak self] in
self?.stopRecording()
}
return
}
}
// 2. Trailing silence timeout: if speech was detected and now silent for 2.0s, auto-finalize!
if hasSpokenInCurrentSession || InputBoxManager.shared.hasStreamedText {
let silenceDuration = now.timeIntervalSince(lastVoiceDetectedTime)
if silenceDuration >= 2.0 {
print("AppDelegate: Hands-free trailing silence detected (2.0s). Auto-finalizing.")
DispatchQueue.main.async { [weak self] in
self?.stopRecording()
}
return
}
}
// 3. Absolute maximum duration guard (60s)
if elapsedSinceStart >= 60.0 {
print("AppDelegate: Hands-free max recording duration reached (60s). Auto-finalizing.")
DispatchQueue.main.async { [weak self] in
self?.stopRecording()
}
return
}
}
@objc private func handleDistributedStartRecording() {
print("AppDelegate: Distributed notification 'com.soniox.voice.start' received.")
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if !self.audioRecorder.isRecording {
self.startRecording()
}
}
}
@objc private func handleDistributedStopRecording() {
print("AppDelegate: Distributed notification 'com.soniox.voice.stop' received.")
DispatchQueue.main.async { [weak self] in
self?.stopRecording()
}
}
@objc private func handleDistributedToggleRecording() {
print("AppDelegate: Distributed notification 'com.soniox.voice.toggle' received.")
DispatchQueue.main.async { [weak self] in
self?.toggleRecording()
}
}
} }

21
src/AudioRecorder.swift

@ -217,14 +217,23 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer
public func stopRecording() -> Data { public func stopRecording() -> Data {
lock.lock() lock.lock()
defer { lock.unlock() }
if !isRecording {
let data = pcmBuffer
lock.unlock()
return data
}
lock.unlock()
if !isRecording { return pcmBuffer }
isRecording = false
// Allow in-flight audio sample buffers on captureQueue to finish processing
usleep(60000)
// Immediately stop hardware capture so macOS orange mic indicator turns OFF
lock.lock()
isRecording = false
captureSession?.stopRunning() captureSession?.stopRunning()
print("AudioRecorder: Stopped. Microphone hardware OFF. Total PCM captured: \(pcmBuffer.count) bytes.")
return pcmBuffer
let data = pcmBuffer
lock.unlock()
print("AudioRecorder: Stopped. Microphone hardware OFF. Total PCM captured: \(data.count) bytes.")
return data
} }
} }

46
src/HUDOverlay.swift

@ -4,10 +4,10 @@ import QuartzCore
public enum HUDState { public enum HUDState {
case hidden case hidden
case recording(level: Float, isLagging: Bool = false, pingQuality: PingQuality? = nil) case recording(level: Float, isLagging: Bool = false, pingQuality: PingQuality? = nil)
case retrying
case retrying(reason: String = "RETRYING...")
case transcribing case transcribing
case success case success
case error
case error(message: String = "ERROR")
} }
public final class HUDOverlayController { public final class HUDOverlayController {
@ -86,26 +86,36 @@ public final class HUDOverlayController {
} }
orb.updateAudioLevel(level, isLagging: isLagging, pingQuality: pingQuality) orb.updateAudioLevel(level, isLagging: isLagging, pingQuality: pingQuality)
case .retrying:
case .retrying(let reason):
if !self.isVisible || window.alphaValue < 0.95 { if !self.isVisible || window.alphaValue < 0.95 {
window.orderFrontRegardless() window.orderFrontRegardless()
window.alphaValue = 1.0 window.alphaValue = 1.0
self.isVisible = true self.isVisible = true
} }
orb.showRetryState()
orb.showRetryState(reason: reason)
case .transcribing: case .transcribing:
if !self.isVisible || window.alphaValue < 0.95 {
window.orderFrontRegardless()
window.alphaValue = 1.0
self.isVisible = true
}
orb.showTranscribingState() orb.showTranscribingState()
case .success: case .success:
orb.showSuccessState() orb.showSuccessState()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
self.hide() self.hide()
} }
case .error:
orb.showErrorState()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.70) {
case .error(let message):
if !self.isVisible || window.alphaValue < 0.95 {
window.orderFrontRegardless()
window.alphaValue = 1.0
self.isVisible = true
}
orb.showErrorState(message: message)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.40) {
self.hide() self.hide()
} }
} }
@ -263,10 +273,14 @@ final class OutwardExpandingOrbView: NSView {
CATransaction.commit() CATransaction.commit()
} }
func showRetryState() {
func showRetryState(reason: String = "RETRYING...") {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(false) CATransaction.setDisableActions(false)
CATransaction.setAnimationDuration(0.20) CATransaction.setAnimationDuration(0.20)
pingTextLayer.string = reason
pingTextLayer.fontSize = reason.count > 8 ? 9.0 : 10.0
pingTextLayer.foregroundColor = NSColor(red: 0.25, green: 0.15, blue: 0.02, alpha: 0.95).cgColor
coreSolidLayer.backgroundColor = NSColor(red: 1.0, green: 0.75, blue: 0.20, alpha: 1.0).cgColor
let amber = NSColor(red: 1.0, green: 0.65, blue: 0.10, alpha: 1.0).cgColor let amber = NSColor(red: 1.0, green: 0.65, blue: 0.10, alpha: 1.0).cgColor
let strokeWidth: CGFloat = 5.0 let strokeWidth: CGFloat = 5.0
let strokeDiameter = baseDiameter + (strokeWidth * 2.0) let strokeDiameter = baseDiameter + (strokeWidth * 2.0)
@ -283,6 +297,10 @@ final class OutwardExpandingOrbView: NSView {
func showTranscribingState() { func showTranscribingState() {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(true) CATransaction.setDisableActions(true)
pingTextLayer.string = "WAIT..."
pingTextLayer.fontSize = 11.0
pingTextLayer.foregroundColor = NSColor.white.cgColor
coreSolidLayer.backgroundColor = NSColor(red: 0.15, green: 0.20, blue: 0.30, alpha: 1.0).cgColor
let strokeWidth: CGFloat = 3.5 let strokeWidth: CGFloat = 3.5
let strokeDiameter = baseDiameter + (strokeWidth * 2.0) let strokeDiameter = baseDiameter + (strokeWidth * 2.0)
dynamicStrokeLayer.bounds = CGRect(x: 0, y: 0, width: strokeDiameter, height: strokeDiameter) dynamicStrokeLayer.bounds = CGRect(x: 0, y: 0, width: strokeDiameter, height: strokeDiameter)
@ -300,6 +318,10 @@ final class OutwardExpandingOrbView: NSView {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(false) CATransaction.setDisableActions(false)
CATransaction.setAnimationDuration(0.18) CATransaction.setAnimationDuration(0.18)
pingTextLayer.string = "OK"
pingTextLayer.fontSize = 11.0
pingTextLayer.foregroundColor = NSColor(red: 0.04, green: 0.20, blue: 0.08, alpha: 0.95).cgColor
coreSolidLayer.backgroundColor = NSColor(red: 0.22, green: 0.82, blue: 0.44, alpha: 1.0).cgColor
let emerald = NSColor(red: 0.18, green: 0.85, blue: 0.38, alpha: 1.0).cgColor // Vibrant Emerald Green let emerald = NSColor(red: 0.18, green: 0.85, blue: 0.38, alpha: 1.0).cgColor // Vibrant Emerald Green
let strokeWidth: CGFloat = 6.0 let strokeWidth: CGFloat = 6.0
let strokeDiameter = baseDiameter + (strokeWidth * 2.0) let strokeDiameter = baseDiameter + (strokeWidth * 2.0)
@ -313,10 +335,14 @@ final class OutwardExpandingOrbView: NSView {
CATransaction.commit() CATransaction.commit()
} }
func showErrorState() {
func showErrorState(message: String = "ERROR") {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(false) CATransaction.setDisableActions(false)
CATransaction.setAnimationDuration(0.18) CATransaction.setAnimationDuration(0.18)
pingTextLayer.string = message
pingTextLayer.fontSize = message.count > 8 ? 9.0 : 10.0
pingTextLayer.foregroundColor = NSColor.white.cgColor
coreSolidLayer.backgroundColor = NSColor(red: 0.94, green: 0.22, blue: 0.22, alpha: 1.0).cgColor
let darkRed = NSColor(red: 0.95, green: 0.15, blue: 0.15, alpha: 1.0).cgColor let darkRed = NSColor(red: 0.95, green: 0.15, blue: 0.15, alpha: 1.0).cgColor
let strokeWidth: CGFloat = 5.0 let strokeWidth: CGFloat = 5.0
let strokeDiameter = baseDiameter + (strokeWidth * 2.0) let strokeDiameter = baseDiameter + (strokeWidth * 2.0)

4
src/HotkeyManager.swift

@ -74,6 +74,10 @@ public final class HotkeyManager {
private var isCapsLockPhysicallyDown = false private var isCapsLockPhysicallyDown = false
private var isF5PhysicallyDown = false private var isF5PhysicallyDown = false
private var isFnPhysicallyDown = false private var isFnPhysicallyDown = false
public var isPhysicalKeyDown: Bool {
return isOptionPhysicallyDown || isCapsLockPhysicallyDown || isF5PhysicallyDown || isFnPhysicallyDown
}
private var lastFnPressTime: TimeInterval = 0 private var lastFnPressTime: TimeInterval = 0
private var lastTriggerTime: TimeInterval = 0 private var lastTriggerTime: TimeInterval = 0

153
src/InputBoxManager.swift

@ -37,12 +37,19 @@ public final class InputBoxManager {
public func ensureFocusedInputBox() { public func ensureFocusedInputBox() {
resetSession() resetSession()
guard AXIsProcessTrusted() else {
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
let isTrusted = AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary)
guard isTrusted else {
print("InputBoxManager: Accessibility not trusted.") print("InputBoxManager: Accessibility not trusted.")
return return
} }
guard let frontApp = NSWorkspace.shared.frontmostApplication else { return } guard let frontApp = NSWorkspace.shared.frontmostApplication else { return }
if let bid = frontApp.bundleIdentifier?.lowercased(),
bid.contains("siri") || bid.contains("shortcuts") {
print("InputBoxManager: Frontmost app is Siri/Shortcuts (\(bid)), preserving user app focus.")
return
}
let appElem = AXUIElementCreateApplication(frontApp.processIdentifier) let appElem = AXUIElementCreateApplication(frontApp.processIdentifier)
// 1. Check if an element is already focused in the frontmost application // 1. Check if an element is already focused in the frontmost application
@ -75,6 +82,18 @@ public final class InputBoxManager {
public func streamDelta(text: String) { public func streamDelta(text: String) {
guard !text.isEmpty else { return } guard !text.isEmpty else { return }
// Suppress trailing end triggers ("and", "تمام", "end") from live typing
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
}
let textWithoutEnd = triggerWords.dropLast().joined(separator: " ") + " "
streamDelta(text: textWithoutEnd)
return
}
lock.lock() lock.lock()
if isPrefixResolved { if isPrefixResolved {
var textToPaste = text var textToPaste = text
@ -219,9 +238,22 @@ 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 }
// Helper to restore full text onto clipboard for the user after injection completes
let restoreClipboardFullText = {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(textWithTrailing, forType: .string)
}
}
// Case 1: Nothing was streamed live -> Paste entire text // Case 1: Nothing was streamed live -> Paste entire text
if cleanStreamed.isEmpty { if cleanStreamed.isEmpty {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(textWithTrailing, forType: .string)
self.postCleanCmdV() self.postCleanCmdV()
restoreClipboardFullText()
return return
} }
@ -233,10 +265,11 @@ public final class InputBoxManager {
pb.setString(" ", forType: .string) pb.setString(" ", forType: .string)
self.postCleanCmdV() self.postCleanCmdV()
} }
restoreClipboardFullText()
return return
} }
// Case 3: Streamed prefix is prefix of trimmed final text
// Case 3: Streamed prefix is exact prefix of trimmed final text
if trimmed.count > cleanStreamed.count && trimmed.hasPrefix(cleanStreamed) { if trimmed.count > cleanStreamed.count && trimmed.hasPrefix(cleanStreamed) {
let suffix = String(trimmed.dropFirst(cleanStreamed.count)) let suffix = String(trimmed.dropFirst(cleanStreamed.count))
let suffixToPaste = addTrailingSpace ? (suffix + " ") : suffix let suffixToPaste = addTrailingSpace ? (suffix + " ") : suffix
@ -244,12 +277,124 @@ public final class InputBoxManager {
pb.clearContents() pb.clearContents()
pb.setString(suffixToPaste, forType: .string) pb.setString(suffixToPaste, forType: .string)
self.postCleanCmdV() self.postCleanCmdV()
restoreClipboardFullText()
return
}
// Case 4: Intelligent Word-level Tail-Anchor Matching (robust to domain vocabulary, number formatting, punctuation, ZWNJ)
let streamedWords = cleanStreamed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
let finalWords = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
guard !finalWords.isEmpty else {
restoreClipboardFullText()
return
}
let cleanWord: (String) -> String = { w in
w.replacingOccurrences(of: "\u{200C}", with: "")
.replacingOccurrences(of: "\u{200D}", with: "")
.replacingOccurrences(of: "[.,!?:;،؛؟\\\"'()\\[\\]{}«»–—\\-]", with: "", options: .regularExpression)
.lowercased()
}
let normStreamed = streamedWords.map(cleanWord).filter { !$0.isEmpty }
let normFinal = finalWords.map(cleanWord).filter { !$0.isEmpty }
let wordsMatch: (String, String) -> Bool = { w1, w2 in
if w1 == w2 { return true }
if w1.count >= 2 && w2.count >= 2 {
if w1.hasPrefix(w2) || w2.hasPrefix(w1) { return true }
}
return false
}
var unstreamedStartIndex: Int? = nil
// Strategy 4A: Search for 3-word tail anchor from streamed text in final words
if normStreamed.count >= 3 {
let a1 = normStreamed[normStreamed.count - 3]
let a2 = normStreamed[normStreamed.count - 2]
let a3 = normStreamed[normStreamed.count - 1]
for j in stride(from: normFinal.count - 1, through: 2, by: -1) {
if wordsMatch(normFinal[j], a3) && wordsMatch(normFinal[j - 1], a2) && wordsMatch(normFinal[j - 2], a1) {
unstreamedStartIndex = j + 1
break
}
}
}
// Strategy 4B: Search for 2-word tail anchor
if unstreamedStartIndex == nil && normStreamed.count >= 2 {
let a1 = normStreamed[normStreamed.count - 2]
let a2 = normStreamed[normStreamed.count - 1]
for j in stride(from: normFinal.count - 1, through: 1, by: -1) {
if wordsMatch(normFinal[j], a2) && wordsMatch(normFinal[j - 1], a1) {
unstreamedStartIndex = j + 1
break
}
}
}
// Strategy 4C: Search for 1-word tail anchor in recent final words
if unstreamedStartIndex == nil && !normStreamed.isEmpty {
let a1 = normStreamed[normStreamed.count - 1]
let searchStart = max(0, normFinal.count - max(15, normStreamed.count))
for j in stride(from: normFinal.count - 1, through: searchStart, by: -1) {
if wordsMatch(normFinal[j], a1) {
unstreamedStartIndex = j + 1
break
}
}
}
// Strategy 4D: Forward prefix match fallback
if unstreamedStartIndex == nil {
var prefixMatchCount = 0
for i in 0..<min(normStreamed.count, normFinal.count) {
if wordsMatch(normStreamed[i], normFinal[i]) {
prefixMatchCount = i + 1
} else {
break
}
}
if prefixMatchCount > 0 {
unstreamedStartIndex = prefixMatchCount
}
}
// Guaranteed Safety Fallback: If no anchor matched, point to streamedWords.count
if unstreamedStartIndex == nil {
unstreamedStartIndex = min(streamedWords.count, finalWords.count)
}
// Suffix Evaluation: NEVER suppress paste! Always paste all remaining unstreamed words!
if let startIndex = unstreamedStartIndex {
if startIndex < finalWords.count {
// Only paste unstreamed trailing words
let remainingWords = Array(finalWords.dropFirst(startIndex))
let suffix = remainingWords.joined(separator: " ")
let suffixToPaste = addTrailingSpace ? (" " + suffix + " ") : (" " + suffix)
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(suffixToPaste, forType: .string)
self.postCleanCmdV()
restoreClipboardFullText()
return
} else {
// startIndex >= finalWords.count: All words were ALREADY streamed!
if addTrailingSpace {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(" ", forType: .string)
self.postCleanCmdV()
}
restoreClipboardFullText()
return
}
} }
restoreClipboardFullText()
} }
} }
/// Posts Cmd+V using privateState event source to cgAnnotatedSessionEventTap
/// This strips physical Option ⌥ / Shift / Control flags and pastes cleanly across all apps
private func postCleanCmdV() { private func postCleanCmdV() {
let src = CGEventSource(stateID: .privateState) let src = CGEventSource(stateID: .privateState)
let vKeyCode: CGKeyCode = 0x09 // 'v' let vKeyCode: CGKeyCode = 0x09 // 'v'

85
src/Intents.swift

@ -0,0 +1,85 @@
import Foundation
import AppIntents
@available(macOS 13.0, *)
public struct StartRecordingIntent: AppIntent {
public static var title: LocalizedStringResource = "Start Recording"
public static var description = IntentDescription("Starts voice recording with Soniox Voice")
public static var openAppWhenRun: Bool = false
public init() {}
@MainActor
public func perform() async throws -> some IntentResult {
print("StartRecordingIntent: Executing startRecording via AppIntent...")
// 1. In-process direct execution if running in the main app
if let app = AppDelegate.shared {
app.startRecording()
}
// 2. Out-of-process IPC notification (for when Shortcuts runner executes this)
DistributedNotificationCenter.default().postNotificationName(
NSNotification.Name("com.soniox.voice.start"),
object: nil,
userInfo: nil,
deliverImmediately: true
)
return .result()
}
}
@available(macOS 13.0, *)
public struct StopRecordingIntent: AppIntent {
public static var title: LocalizedStringResource = "Stop Recording"
public static var description = IntentDescription("Stops active voice recording and finalizes text paste")
public static var openAppWhenRun: Bool = false
public init() {}
@MainActor
public func perform() async throws -> some IntentResult {
print("StopRecordingIntent: Executing stopRecording via AppIntent...")
if let app = AppDelegate.shared {
app.stopRecording()
}
DistributedNotificationCenter.default().postNotificationName(
NSNotification.Name("com.soniox.voice.stop"),
object: nil,
userInfo: nil,
deliverImmediately: true
)
return .result()
}
}
@available(macOS 13.0, *)
public struct ToggleRecordingIntent: AppIntent {
public static var title: LocalizedStringResource = "Toggle Recording"
public static var description = IntentDescription("Toggles voice recording on or off")
public static var openAppWhenRun: Bool = false
public init() {}
@MainActor
public func perform() async throws -> some IntentResult {
print("ToggleRecordingIntent: Executing toggleRecording via AppIntent...")
if let app = AppDelegate.shared {
app.toggleRecording()
}
DistributedNotificationCenter.default().postNotificationName(
NSNotification.Name("com.soniox.voice.toggle"),
object: nil,
userInfo: nil,
deliverImmediately: true
)
return .result()
}
}

270
src/SonioxClient.swift

@ -23,18 +23,27 @@ public final class SonioxLiveSession {
// Transcripts tracking // Transcripts tracking
private var committedFinalTokens: [String] = [] private var committedFinalTokens: [String] = []
private var currentNonFinalTokens: [String] = [] private var currentNonFinalTokens: [String] = []
private var streamedPrefix: String = ""
// Callbacks // Callbacks
public var onPartialText: ((String) -> Void)? public var onPartialText: ((String) -> Void)?
public var onCommittedDelta: ((String) -> Void)? public var onCommittedDelta: ((String) -> Void)?
public var onEndTriggerDetected: (() -> Void)?
public var onFinalResult: ((Result<String, Error>) -> Void)? public var onFinalResult: ((Result<String, Error>) -> Void)?
public var onConnectionStateChanged: ((Bool) -> Void)? public var onConnectionStateChanged: ((Bool) -> Void)?
public var onNetworkDegraded: ((Bool) -> Void)? public var onNetworkDegraded: ((Bool) -> Void)?
public private(set) var hasNetworkFailure: Bool = false public private(set) var hasNetworkFailure: Bool = false
public private(set) var isRecordingActive: Bool = false
private var lastDataTime: Date = Date() private var lastDataTime: Date = Date()
private var heartbeatTimer: Timer? private var heartbeatTimer: Timer?
public func setRecordingActive(_ active: Bool) {
lock.lock()
isRecordingActive = active
lock.unlock()
}
public var isReady: Bool { public var isReady: Bool {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
@ -148,6 +157,7 @@ public final class SonioxLiveSession {
print("SonioxLiveSession: Error streaming audio chunk:", error) print("SonioxLiveSession: Error streaming audio chunk:", error)
self?.lock.lock() self?.lock.lock()
self?.hasNetworkFailure = true self?.hasNetworkFailure = true
self?.isConnected = false
self?.lock.unlock() self?.lock.unlock()
self?.onNetworkDegraded?(true) self?.onNetworkDegraded?(true)
} }
@ -164,8 +174,9 @@ public final class SonioxLiveSession {
lock.unlock() lock.unlock()
print("SonioxLiveSession: Sending finalize packet...") print("SonioxLiveSession: Sending finalize packet...")
// 1. Send trailing 120ms silence frame so acoustic model cleanly resolves trailing syllables
let silenceChunk = Data(count: 3840)
// 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 silenceMsg = URLSessionWebSocketTask.Message.data(silenceChunk) let silenceMsg = URLSessionWebSocketTask.Message.data(silenceChunk)
task.send(silenceMsg) { _ in } task.send(silenceMsg) { _ in }
@ -181,14 +192,14 @@ public final class SonioxLiveSession {
} }
} }
// Safety timeout fallback: finalize must complete within 3.5s
DispatchQueue.global().asyncAfter(deadline: .now() + 3.5) { [weak self] in
// Safety timeout fallback: allow up to 4.5s for STT inference & network transit
DispatchQueue.global().asyncAfter(deadline: .now() + 4.5) { [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.lock.unlock() self.lock.unlock()
if stillOpen { if stillOpen {
print("SonioxLiveSession: Finalize timeout reached — completing.")
print("SonioxLiveSession: Finalize timeout (4.5s) reached — completing.")
self.completeWithCurrentText() self.completeWithCurrentText()
} }
} }
@ -228,9 +239,18 @@ public final class SonioxLiveSession {
print("SonioxLiveSession: WebSocket receive error:", error) print("SonioxLiveSession: WebSocket receive error:", error)
self.lock.lock() self.lock.lock()
self.hasNetworkFailure = true self.hasNetworkFailure = true
self.isConnected = false
let activeRecording = self.isRecordingActive
self.lock.unlock() self.lock.unlock()
self.onNetworkDegraded?(true) self.onNetworkDegraded?(true)
self.completeWithCurrentText()
// If recording is still active (user speaking), keep buffering in AudioRecorder;
// do NOT complete or deliver empty text prematurely!
if !activeRecording {
self.completeWithCurrentText()
} else {
print("SonioxLiveSession: Network dropped mid-recording. Audio is buffered for Smart Recovery upon key release.")
}
} }
} }
} }
@ -285,11 +305,41 @@ public final class SonioxLiveSession {
let fullCommitted = committedFinalTokens.joined() let fullCommitted = committedFinalTokens.joined()
let fullNonFinal = currentNonFinalTokens.joined() let fullNonFinal = currentNonFinalTokens.joined()
let combined = fullCommitted + fullNonFinal let combined = fullCommitted + fullNonFinal
// Check for in-flight end trigger ("and", "تمام", "end") in combined text
var triggerDetected = false
let trimmedCombined = combined.trimmingCharacters(in: .whitespacesAndNewlines)
let currentWords = trimmedCombined.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
if let lastRaw = currentWords.last {
let cleanLast = lastRaw.replacingOccurrences(of: #"[.,!?:;،؛؟"'()\[\]{}«»–—\-]"#, with: "", options: .regularExpression).lowercased()
if cleanLast == "and" || cleanLast == "تمام" || cleanLast == "end" {
triggerDetected = true
}
}
// Emit live streaming deltas STRICTLY from Soniox-finalized tokens (is_final == true):
var deltaToEmit: String? = nil
if !triggerDetected && fullCommitted.count > streamedPrefix.count {
if fullCommitted.hasPrefix(streamedPrefix) {
let delta = String(fullCommitted.dropFirst(streamedPrefix.count))
streamedPrefix = fullCommitted
deltaToEmit = delta
} else {
let common = fullCommitted.commonPrefix(with: streamedPrefix)
if fullCommitted.count > common.count {
let delta = String(fullCommitted.dropFirst(common.count))
streamedPrefix = fullCommitted
deltaToEmit = delta
}
}
}
lock.unlock() lock.unlock()
// Emit streaming delta for newly committed words
if !newFinals.isEmpty {
let delta = newFinals.joined()
if triggerDetected {
DispatchQueue.main.async { [weak self] in
self?.onEndTriggerDetected?()
}
} else if let delta = deltaToEmit, !delta.isEmpty {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.onCommittedDelta?(delta) self?.onCommittedDelta?(delta)
} }
@ -321,6 +371,7 @@ public final class SonioxLiveSession {
let fullNonFinal = currentNonFinalTokens.joined() let fullNonFinal = currentNonFinalTokens.joined()
let rawCombined = fullCommitted.isEmpty ? fullNonFinal : (fullCommitted + fullNonFinal) let rawCombined = fullCommitted.isEmpty ? fullNonFinal : (fullCommitted + fullNonFinal)
let cleaned = SonioxLiveSession.sanitizeText(rawCombined) let cleaned = SonioxLiveSession.sanitizeText(rawCombined)
let failureOccurred = hasNetworkFailure
let cb = onFinalResult let cb = onFinalResult
webSocketTask?.cancel(with: .normalClosure, reason: nil) webSocketTask?.cancel(with: .normalClosure, reason: nil)
@ -329,7 +380,11 @@ public final class SonioxLiveSession {
lock.unlock() lock.unlock()
DispatchQueue.main.async { DispatchQueue.main.async {
cb?(.success(cleaned))
if cleaned.isEmpty && failureOccurred {
cb?(.failure(NSError(domain: "SonioxLiveSession", code: -101, userInfo: [NSLocalizedDescriptionKey: "WebSocket network failure with empty transcript"])))
} else {
cb?(.success(cleaned))
}
} }
} }
@ -353,23 +408,47 @@ public final class SonioxLiveSession {
completion: @escaping (Result<String, Error>) -> Void completion: @escaping (Result<String, Error>) -> Void
) { ) {
guard !pcmData.isEmpty else { guard !pcmData.isEmpty else {
completion(.success(""))
completion(.failure(NSError(domain: "SonioxLiveSession", code: -102, userInfo: [NSLocalizedDescriptionKey: "Empty audio buffer for recovery"])))
return return
} }
let effectiveTarget = targetLanguage ?? (SonioxSettings.shared.targetLanguage.isEnabled ? SonioxSettings.shared.targetLanguage.rawValue : nil) let effectiveTarget = targetLanguage ?? (SonioxSettings.shared.targetLanguage.isEnabled ? SonioxSettings.shared.targetLanguage.rawValue : nil)
print("SonioxLiveSession: Starting Smart Burst Retry with \(pcmData.count) bytes (\(Double(pcmData.count)/32000.0)s audio, target: \(effectiveTarget ?? "orig"))...")
// Try Primary endpoint, failover to Fallback if needed
executeBurstSession(endpoint: primaryWsBase, pcmData: pcmData, languageHints: languageHints, targetLanguage: effectiveTarget, onProgress: onProgress) { result in
switch result {
case .success(let text):
completion(.success(text))
case .failure(let error):
print("SonioxLiveSession: Primary retry failed (\(error)), attempting fallback endpoint...")
executeBurstSession(endpoint: fallbackWsBase, pcmData: pcmData, languageHints: languageHints, targetLanguage: effectiveTarget, onProgress: onProgress, completion: completion)
let durationSec = Double(pcmData.count) / 32000.0
print("SonioxLiveSession: Initiating Smart Burst Recovery: \(pcmData.count) bytes (\(String(format: "%.1f", durationSec))s audio, target: \(effectiveTarget ?? "orig"))...")
// Multi-stage resilient retry pipeline across dual endpoints with automatic backoff:
// Attempt 1: Primary WS
// Attempt 2: Fallback WS
// Attempt 3: (pause 0.8s for network interface/VPN to stabilize) Primary WS
// Attempt 4: Fallback WS
func executeAttempt(_ step: Int) {
let isPrimary = (step == 1 || step == 3)
let endpoint = isPrimary ? primaryWsBase : fallbackWsBase
let name = isPrimary ? "Primary" : "Fallback"
print("SonioxLiveSession: Smart Recovery Attempt \(step)/4 on \(name) endpoint...")
executeBurstSession(endpoint: endpoint, pcmData: pcmData, languageHints: languageHints, targetLanguage: effectiveTarget, onProgress: onProgress) { result in
switch result {
case .success(let text):
print("SonioxLiveSession: Recovery Attempt \(step) SUCCEEDED (\(text.count) chars).")
completion(.success(text))
case .failure(let error):
print("SonioxLiveSession: Recovery Attempt \(step) failed: \(error)")
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) {
executeAttempt(step + 1)
}
} else {
print("SonioxLiveSession: All 4 recovery attempts exhausted: \(error)")
completion(.failure(error))
}
}
} }
} }
executeAttempt(1)
} }
private static func executeBurstSession( private static func executeBurstSession(
@ -402,28 +481,33 @@ public final class SonioxLiveSession {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.setValue(userAgent, forHTTPHeaderField: "User-Agent") request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
request.setValue(origin, forHTTPHeaderField: "Origin") request.setValue(origin, forHTTPHeaderField: "Origin")
request.timeoutInterval = 25.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 config = URLSessionConfiguration.default 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()
var fullCommitted: [String] = [] var fullCommitted: [String] = []
var fullNonFinal: [String] = []
var latestNonFinal: [String] = []
var isDone = false var isDone = false
let lock = NSLock() let lock = NSLock()
func finishOnce(with text: String) {
func finishOnce(with result: Result<String, Error>) {
lock.lock() lock.lock()
if isDone { lock.unlock(); return } if isDone { lock.unlock(); return }
isDone = true isDone = true
task.cancel(with: .normalClosure, reason: nil) task.cancel(with: .normalClosure, reason: nil)
session.finishTasksAndInvalidate()
lock.unlock() lock.unlock()
let sanitized = sanitizeText(text)
DispatchQueue.main.async { DispatchQueue.main.async {
completion(.success(sanitized))
completion(result)
} }
} }
@ -447,6 +531,7 @@ public final class SonioxLiveSession {
let json = try? JSONSerialization.jsonObject(with: jData) as? [String: Any] { let json = try? JSONSerialization.jsonObject(with: jData) as? [String: Any] {
var gotFin = false var gotFin = false
var packetNonFinal: [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"
@ -464,59 +549,111 @@ public final class SonioxLiveSession {
} else if isFinal { } else if isFinal {
if !t.isEmpty { fullCommitted.append(t) } if !t.isEmpty { fullCommitted.append(t) }
} else { } else {
if !t.isEmpty { fullNonFinal.append(t) }
if !t.isEmpty { packetNonFinal.append(t) }
} }
} }
} }
let combined = fullCommitted.joined() + fullNonFinal.joined()
if !combined.isEmpty {
lock.lock()
latestNonFinal = packetNonFinal
let currentProgress = fullCommitted.joined() + (gotFin ? "" : latestNonFinal.joined())
lock.unlock()
if !currentProgress.isEmpty {
DispatchQueue.main.async { DispatchQueue.main.async {
onProgress?(combined)
onProgress?(currentProgress)
} }
} }
let ended = (json["session_ended"] as? Bool ?? false) || ((json["type"] as? String) == "session_done") let ended = (json["session_ended"] as? Bool ?? false) || ((json["type"] as? String) == "session_done")
if gotFin || ended { if gotFin || ended {
finishOnce(with: combined)
lock.lock()
let textToReturn = fullCommitted.joined() + (gotFin ? "" : latestNonFinal.joined())
lock.unlock()
let sanitized = sanitizeText(textToReturn)
if !sanitized.isEmpty {
finishOnce(with: .success(sanitized))
} else {
finishOnce(with: .failure(NSError(domain: "Soniox", code: -4, userInfo: [NSLocalizedDescriptionKey: "Server returned empty transcript"])))
}
return return
} }
} }
receiveNext() receiveNext()
case .failure(let err): case .failure(let err):
print("SonioxLiveSession: Burst receive error:", err)
let combined = fullCommitted.joined() + fullNonFinal.joined()
finishOnce(with: combined)
print("SonioxLiveSession: Burst receive error on \(endpoint):", err)
lock.lock()
let textToReturn = fullCommitted.joined() + latestNonFinal.joined()
lock.unlock()
let sanitized = sanitizeText(textToReturn)
if !sanitized.isEmpty {
finishOnce(with: .success(sanitized))
} else {
finishOnce(with: .failure(err))
}
} }
} }
} }
receiveNext() receiveNext()
// Pump audio in high-speed bursts (16KB chunks)
DispatchQueue.global(qos: .userInitiated).async {
let chunkSize = 16000 // 0.5s audio
var offset = 0
while offset < pcmData.count {
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(8000) // 8ms pace
// Handshake verification before pumping audio to avoid early drops & empty transcripts
task.sendPing { pingError in
lock.lock()
let alreadyDone = isDone
lock.unlock()
if alreadyDone { return }
if let pingErr = pingError {
print("SonioxLiveSession: Burst connection ping failed on \(endpoint): \(pingErr)")
finishOnce(with: .failure(pingErr))
return
} }
// Send trailing silence + finalize
let silence = Data(count: 3840)
task.send(URLSessionWebSocketTask.Message.data(silence)) { _ in }
let finalizeMsg = URLSessionWebSocketTask.Message.string("{\"type\": \"finalize\"}")
task.send(finalizeMsg) { _ in }
// 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
}
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 }
}
}
} }
// Watchdog timeout for burst retry: max 2.5 seconds (never hangs UI)
DispatchQueue.global().asyncAfter(deadline: .now() + 2.5) {
let combined = fullCommitted.joined() + fullNonFinal.joined()
finishOnce(with: combined)
// Dynamic Watchdog timeout
DispatchQueue.global().asyncAfter(deadline: .now() + watchdogTimeout) {
lock.lock()
let textToReturn = fullCommitted.joined() + latestNonFinal.joined()
lock.unlock()
let sanitized = sanitizeText(textToReturn)
if !sanitized.isEmpty {
print("SonioxLiveSession: Burst watchdog fired, salvaging partial text: \(sanitized.prefix(30))...")
finishOnce(with: .success(sanitized))
} else {
print("SonioxLiveSession: Burst watchdog timed out after \(watchdogTimeout)s with no text.")
finishOnce(with: .failure(NSError(domain: "Soniox", code: -5, userInfo: [NSLocalizedDescriptionKey: "Burst watchdog timed out after \(watchdogTimeout)s"])))
}
} }
} }
@ -637,16 +774,29 @@ public final class SonioxSessionPool {
let target = SonioxSettings.shared.targetLanguage.isEnabled ? SonioxSettings.shared.targetLanguage.rawValue : nil let target = SonioxSettings.shared.targetLanguage.isEnabled ? SonioxSettings.shared.targetLanguage.rawValue : nil
let session = SonioxLiveSession(languageHints: hints, targetLanguage: target) let session = SonioxLiveSession(languageHints: hints, targetLanguage: target)
// Allow 400ms for connection & initial ping
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
session.onConnectionStateChanged = { [weak self, weak session] connected in
guard let self = self, let session = session else { return }
self.lock.lock()
defer { self.lock.unlock() }
if connected {
session.startKeepAliveHeartbeat()
self.prewarmedSession = session
self.prewarmedSessionCreatedAt = Date()
self.isPrewarming = false
print("SonioxSessionPool: Fresh prewarmed session connected & ready.")
} else {
self.isPrewarming = false
}
}
// Watchdog in case connection hangs indefinitely
DispatchQueue.global().asyncAfter(deadline: .now() + 15.0) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.lock.lock() self.lock.lock()
session.startKeepAliveHeartbeat()
self.prewarmedSession = session
self.prewarmedSessionCreatedAt = Date()
self.isPrewarming = false
if self.isPrewarming && self.prewarmedSession == nil {
self.isPrewarming = false
}
self.lock.unlock() self.lock.unlock()
print("SonioxSessionPool: Fresh prewarmed session initialized & heartbeat started.")
} }
} }
} }

10
src/SonioxSettings.swift

@ -163,6 +163,7 @@ public final class SonioxSettings {
} }
} }
public var voicePunctuation: Bool { public var voicePunctuation: Bool {
get { get {
if defaults.object(forKey: "SonioxVoicePunctuation") == nil { return true } if defaults.object(forKey: "SonioxVoicePunctuation") == nil { return true }
@ -213,6 +214,15 @@ public final class SonioxSettings {
} }
} }
public var liveStreamingInput: Bool {
get {
return defaults.bool(forKey: "SonioxLiveStreamingInput")
}
set {
defaults.set(newValue, forKey: "SonioxLiveStreamingInput")
}
}
public var playSounds: Bool { public var playSounds: Bool {
get { get {
if defaults.object(forKey: "SonioxPlaySounds") == nil { return true } if defaults.object(forKey: "SonioxPlaySounds") == nil { return true }

5
src/StatusBarController.swift

@ -73,7 +73,7 @@ public final class StatusBarController: NSObject, NSMenuDelegate {
recordItem.target = self recordItem.target = self
menu.addItem(recordItem) menu.addItem(recordItem)
menu.addItem(NSMenuItem.separator())
// 2. Dictation Mode Submenu // 2. Dictation Mode Submenu
let modeMenu = NSMenu() let modeMenu = NSMenu()
@ -593,4 +593,7 @@ public final class StatusBarController: NSObject, NSMenuDelegate {
@objc private func quitAction() { @objc private func quitAction() {
NSApplication.shared.terminate(nil) NSApplication.shared.terminate(nil)
} }
} }

20
src/TextProcessor.swift

@ -54,6 +54,9 @@ public final class TextProcessor {
result = normalizeText(result) result = normalizeText(result)
} }
// 5. Wake Word & End Trigger sanitization
result = stripTrailingEndTrigger(result)
return result.trimmingCharacters(in: .whitespacesAndNewlines) return result.trimmingCharacters(in: .whitespacesAndNewlines)
} }
@ -253,4 +256,21 @@ public final class TextProcessor {
} }
return t.trimmingCharacters(in: .whitespacesAndNewlines) return t.trimmingCharacters(in: .whitespacesAndNewlines)
} }
public func stripTrailingEndTrigger(_ text: String) -> String {
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: "")
}
return t.trimmingCharacters(in: .whitespacesAndNewlines)
}
} }

24
src/VoiceCommandLogger.swift

@ -82,6 +82,30 @@ public final class VoiceCommandLogger {
} }
} }
public func logRecovery(
audioBytes: Int,
durationSec: Double,
reason: String,
outcome: String,
recoveredText: String
) {
queue.async {
let timestamp = self.dateFormatter.string(from: Date())
let statusIcon = outcome.contains("SUCCESS") ? "✅" : "⚠️"
let entry = """
--------------------------------------------------------------------------------
[\(timestamp)] 🔄 SMART AUDIO RECOVERY
⏱ Audio Length: \(String(format: "%.1f", durationSec))s (\(audioBytes) bytes)
⚡️ Trigger Reason: \(reason)
\(statusIcon) Recovery Outcome: \(outcome)
📝 Recovered Text: "\(recoveredText)"
--------------------------------------------------------------------------------
"""
self.appendToFile(text: entry)
}
}
private func appendToFile(text: String) { private func appendToFile(text: String) {
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) {

33
src/main.swift

@ -1,5 +1,38 @@
import Cocoa import Cocoa
func checkSingleInstance() {
let myPid = ProcessInfo.processInfo.processIdentifier
// 1. Check if another instance with bundle ID "com.soniox.voice" is already running
if let bundleID = Bundle.main.bundleIdentifier {
let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID)
for app in runningApps {
if app.processIdentifier != myPid {
fputs("SonioxVoice: Another instance (PID \(app.processIdentifier)) is already running. Exiting duplicate PID \(myPid).\n", stderr)
exit(0)
}
}
}
// 2. Fallback OS-level file lock (flock) on lockfile to prevent race conditions
let lockPath = NSTemporaryDirectory() + "com.soniox.voice.lock"
let fd = open(lockPath, O_CREAT | O_RDWR, 0o666)
if fd >= 0 {
if flock(fd, LOCK_EX | LOCK_NB) != 0 {
fputs("SonioxVoice: Lock file \(lockPath) is locked by another process. Exiting duplicate PID \(myPid).\n", stderr)
exit(0)
}
ftruncate(fd, 0)
let pidStr = "\(myPid)\n"
pidStr.withCString { ptr in
_ = write(fd, ptr, strlen(ptr))
}
// Keep fd open for process lifetime to maintain lock
}
}
checkSingleInstance()
let app = NSApplication.shared let app = NSApplication.shared
let delegate = AppDelegate() let delegate = AppDelegate()
app.delegate = delegate app.delegate = delegate

Loading…
Cancel
Save