Browse Source

fix: disable live unfinalized delta pasting into input box to prevent garbled text and duplicate pasting

main
Ali Alavi 5 days ago
parent
commit
1fd339334a
  1. 142
      src/AppDelegate.swift
  2. 9
      src/SonioxSettings.swift

142
src/AppDelegate.swift

@ -3,21 +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 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()
@ -65,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))
} }
@ -146,6 +182,15 @@ 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()
@ -180,12 +225,17 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
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 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
} }
InputBoxManager.shared.streamDelta(text: delta) InputBoxManager.shared.streamDelta(text: delta)
} }
}
session.onEndTriggerDetected = { [weak self] in session.onEndTriggerDetected = { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@ -198,12 +248,17 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
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 user is STILL speaking (Option held / recording active), do NOT finish early!
if self.audioRecorder.isRecording { if self.audioRecorder.isRecording {
print("AppDelegate: Session finished/failed while user is still speaking. Marking degraded; recovery will run upon release.")
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 self.isNetworkDegraded = true
HUDOverlayController.shared.show(state: .recording(level: self.currentAudioLevel, isLagging: true)) HUDOverlayController.shared.show(state: .recording(level: self.currentAudioLevel, isLagging: true))
return return
} else {
print("AppDelegate: Session finished in hands-free/Siri mode. Stopping recording and finalizing.")
self.stopRecording()
return
}
} }
self.handleFinalResult(result) self.handleFinalResult(result)
} }
@ -245,6 +300,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
} }
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 }
@ -425,14 +482,17 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
} }
public func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { 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 DispatchQueue.main.async { [weak self] in
guard let self = self else { return } 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() self.startRecording()
} }
} }
return false
return true
} }
public func application(_ application: NSApplication, open urls: [URL]) { 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, stopCb, "com.soniox.voice.stop" as CFString, nil, .deliverImmediately)
CFNotificationCenterAddObserver(notifyCenter, selfPtr, toggleCb, "com.soniox.voice.toggle" 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()
}
}
} }

9
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 { public var playSounds: Bool {
get { get {
if defaults.object(forKey: "SonioxPlaySounds") == nil { return true } if defaults.object(forKey: "SonioxPlaySounds") == nil { return true }

Loading…
Cancel
Save