From 1fd339334a089a96d63342734230ab578c0e05e4 Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Sun, 4 Oct 2026 18:43:17 +0000 Subject: [PATCH] fix: disable live unfinalized delta pasting into input box to prevent garbled text and duplicate pasting --- src/AppDelegate.swift | 156 ++++++++++++++++++++++++++++++++++++--- src/SonioxSettings.swift | 9 +++ 2 files changed, 153 insertions(+), 12 deletions(-) diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 4aeb3bb..9d203b7 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -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() + } + } } diff --git a/src/SonioxSettings.swift b/src/SonioxSettings.swift index 0cd8093..b81694f 100644 --- a/src/SonioxSettings.swift +++ b/src/SonioxSettings.swift @@ -214,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 { get { if defaults.object(forKey: "SonioxPlaySounds") == nil { return true }