|
|
|
@ -3,21 +3,53 @@ import AVFoundation |
|
|
|
import ApplicationServices |
|
|
|
|
|
|
|
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 audioRecorder = AudioRecorder() |
|
|
|
private var activeSession: SonioxLiveSession? |
|
|
|
private var isBusyFinalizing = false |
|
|
|
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 isNetworkDegraded: Bool = false |
|
|
|
private var lastRecordedAudio: Data = Data() |
|
|
|
private var finalizeWatchdogWorkItem: DispatchWorkItem? = nil |
|
|
|
|
|
|
|
public func applicationDidFinishLaunching(_ notification: Notification) { |
|
|
|
setbuf(stdout, nil) |
|
|
|
setbuf(stderr, nil) |
|
|
|
if UserDefaults.standard.object(forKey: "SonioxPlaySounds") == nil { |
|
|
|
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.onToggleRecording = { [weak self] in |
|
|
|
self?.toggleRecording() |
|
|
|
@ -65,6 +97,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
audioRecorder.onAudioLevelUpdate = { [weak self] level in |
|
|
|
guard let self = self else { return } |
|
|
|
self.currentAudioLevel = level |
|
|
|
if level > 0.07 { |
|
|
|
self.hasSpokenInCurrentSession = true |
|
|
|
self.lastVoiceDetectedTime = Date() |
|
|
|
} |
|
|
|
if self.audioRecorder.isRecording { |
|
|
|
HUDOverlayController.shared.show(state: .recording(level: level, isLagging: self.isNetworkDegraded, pingQuality: PingMonitor.shared.currentQuality)) |
|
|
|
} |
|
|
|
@ -146,6 +182,15 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
|
|
|
|
isNetworkDegraded = false |
|
|
|
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 |
|
|
|
InputBoxManager.shared.ensureFocusedInputBox() |
|
|
|
@ -180,11 +225,16 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// If it's an executive slash command, do NOT type it into the input box |
|
|
|
if SonioxSettings.shared.voiceCommands && VoiceCommandExecutor.shared.isSlashCommand(text: delta) { |
|
|
|
return |
|
|
|
self.hasSpokenInCurrentSession = true |
|
|
|
self.lastVoiceDetectedTime = Date() |
|
|
|
|
|
|
|
if SonioxSettings.shared.liveStreamingInput { |
|
|
|
// If it's an executive slash command, do NOT type it into the input box |
|
|
|
if SonioxSettings.shared.voiceCommands && VoiceCommandExecutor.shared.isSlashCommand(text: delta) { |
|
|
|
return |
|
|
|
} |
|
|
|
InputBoxManager.shared.streamDelta(text: delta) |
|
|
|
} |
|
|
|
InputBoxManager.shared.streamDelta(text: delta) |
|
|
|
} |
|
|
|
|
|
|
|
session.onEndTriggerDetected = { [weak self] in |
|
|
|
@ -198,12 +248,17 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
session.onFinalResult = { [weak self] result in |
|
|
|
DispatchQueue.main.async { |
|
|
|
guard let self = self else { return } |
|
|
|
// If user is STILL speaking (Option held / recording active), do NOT finish early! |
|
|
|
if self.audioRecorder.isRecording { |
|
|
|
print("AppDelegate: Session finished/failed while user is still speaking. Marking degraded; recovery will run upon release.") |
|
|
|
self.isNetworkDegraded = true |
|
|
|
HUDOverlayController.shared.show(state: .recording(level: self.currentAudioLevel, isLagging: true)) |
|
|
|
return |
|
|
|
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) |
|
|
|
} |
|
|
|
@ -245,6 +300,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
} |
|
|
|
|
|
|
|
public func stopRecording() { |
|
|
|
handsFreeSilenceTimer?.invalidate() |
|
|
|
handsFreeSilenceTimer = nil |
|
|
|
HotkeyManager.shared.resetKeyState() |
|
|
|
guard audioRecorder.isRecording else { return } |
|
|
|
|
|
|
|
@ -425,14 +482,17 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
} |
|
|
|
|
|
|
|
public func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { |
|
|
|
print("AppDelegate: Application reopen triggered. Starting voice recording...") |
|
|
|
print("AppDelegate: Application reopen triggered (Siri / Shortcut).") |
|
|
|
DispatchQueue.main.async { [weak self] in |
|
|
|
guard let self = self else { return } |
|
|
|
if !self.audioRecorder.isRecording { |
|
|
|
if self.audioRecorder.isRecording { |
|
|
|
print("AppDelegate: Already recording on reopen — stopping and finalizing.") |
|
|
|
self.stopRecording() |
|
|
|
} else { |
|
|
|
self.startRecording() |
|
|
|
} |
|
|
|
} |
|
|
|
return false |
|
|
|
return true |
|
|
|
} |
|
|
|
|
|
|
|
public func application(_ application: NSApplication, open urls: [URL]) { |
|
|
|
@ -499,4 +559,76 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
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() |
|
|
|
} |
|
|
|
} |
|
|
|
} |