diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index e390152..312be8d 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -165,7 +165,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { playSystemSound(name: "Tink") statusBarController.updateIcon(state: .recording) statusBarController.buildMenu(isRecording: true) - HUDOverlayController.shared.show(state: .recording(level: 0.1, isLagging: false)) + HUDOverlayController.shared.show(state: .recording(level: 0.1, isLagging: false, pingQuality: PingMonitor.shared.currentQuality)) } catch { print("Failed to start audio recording:", error) activeSession?.cancel() @@ -194,6 +194,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { } public func stopRecording() { + HotkeyManager.shared.resetKeyState() guard audioRecorder.isRecording else { return } let pcmData = audioRecorder.stopRecording() diff --git a/src/AudioRecorder.swift b/src/AudioRecorder.swift index 7fb89aa..de1cc19 100644 --- a/src/AudioRecorder.swift +++ b/src/AudioRecorder.swift @@ -19,6 +19,47 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer public override init() { self.targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: false)! super.init() + setupLifecycleObservers() + } + + private func setupLifecycleObservers() { + NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] _ in + print("AudioRecorder: Mac going to sleep. Resetting audio hardware session.") + self?.resetSession() + } + + NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { [weak self] _ in + print("AudioRecorder: Mac woke from sleep. Resetting and priming audio session in 1.0s...") + self?.resetSession() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + try? self?.ensureSessionConfigured() + } + } + + NotificationCenter.default.addObserver(forName: .AVCaptureSessionRuntimeError, object: nil, queue: .main) { [weak self] notif in + print("AudioRecorder: AVCaptureSessionRuntimeError received. Resetting capture graph:", notif) + self?.resetSession() + } + + NotificationCenter.default.addObserver(forName: .AVCaptureSessionWasInterrupted, object: nil, queue: .main) { [weak self] _ in + print("AudioRecorder: AVCaptureSessionWasInterrupted received. Resetting session.") + self?.resetSession() + } + } + + public func resetSession() { + lock.lock() + defer { lock.unlock() } + isRecording = false + if let session = captureSession { + if session.isRunning { + session.stopRunning() + } + } + captureSession = nil + audioOutput = nil + audioConverter = nil + print("AudioRecorder: Hardware capture session reset.") } public func requestMicrophonePermission(completion: @escaping (Bool) -> Void) { diff --git a/src/HUDOverlay.swift b/src/HUDOverlay.swift index 1002329..eb41008 100644 --- a/src/HUDOverlay.swift +++ b/src/HUDOverlay.swift @@ -23,6 +23,9 @@ public final class HUDOverlayController { private init() { DispatchQueue.main.async { self.setupWindow() + NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main) { [weak self] _ in + self?.reposition() + } } } diff --git a/src/HotkeyManager.swift b/src/HotkeyManager.swift index 4e8db81..1f6d103 100644 --- a/src/HotkeyManager.swift +++ b/src/HotkeyManager.swift @@ -79,8 +79,8 @@ public final class HotkeyManager { public var currentPreset: HotkeyPreset { get { - let val = UserDefaults.standard.string(forKey: "SonioxHotkeyPreset") ?? HotkeyPreset.micKey.rawValue - return HotkeyPreset(rawValue: val) ?? .micKey + let val = UserDefaults.standard.string(forKey: "SonioxHotkeyPreset") ?? HotkeyPreset.option.rawValue + return HotkeyPreset(rawValue: val) ?? .option } set { UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxHotkeyPreset") @@ -99,10 +99,48 @@ public final class HotkeyManager { } } - private init() {} + private init() { + setupSleepWakeObservers() + } + + private func setupSleepWakeObservers() { + NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] _ in + guard let self = self else { return } + print("HotkeyManager: Mac entering sleep. Resetting keys & unregistering taps.") + self.resetKeyState() + self.unregisterHotkeys() + } + + NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { [weak self] _ in + guard let self = self else { return } + print("HotkeyManager: Mac woke from sleep. Re-registering hotkeys in 0.5s...") + self.resetKeyState() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.registerHotkeys() + } + } + + NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.screensDidWakeNotification, object: nil, queue: .main) { [weak self] _ in + guard let self = self else { return } + print("HotkeyManager: Screens woke. Verifying hotkey event tap...") + if let tap = self.eventTapPort { + CGEvent.tapEnable(tap: tap, enable: true) + } else { + self.registerHotkeys() + } + } + } + + public func resetKeyState() { + isOptionPhysicallyDown = false + isF5PhysicallyDown = false + isCapsLockPhysicallyDown = false + isFnPhysicallyDown = false + } public func registerHotkeys() { unregisterHotkeys() + resetKeyState() let preset = currentPreset print("HotkeyManager: Registering hotkey for '\(preset.rawValue)', mode: '\(currentMode.rawValue)'") @@ -150,22 +188,6 @@ public final class HotkeyManager { // 3. Dual-layer fallback NSEvent monitor (catches flagsChanged/Option even after wake/sleep) setupGlobalMonitor() - - // 4. Watch for system and display sleep/wake to ensure event tap is always alive - NotificationCenter.default.removeObserver(self, name: NSWorkspace.didWakeNotification, object: nil) - NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { [weak self] _ in - print("HotkeyManager: System woke from sleep. Re-registering hotkeys...") - self?.registerHotkeys() - } - NotificationCenter.default.removeObserver(self, name: NSWorkspace.screensDidWakeNotification, object: nil) - NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.screensDidWakeNotification, object: nil, queue: .main) { [weak self] _ in - print("HotkeyManager: Screens woke. Verifying hotkey event tap...") - if let tap = self?.eventTapPort { - CGEvent.tapEnable(tap: tap, enable: true) - } else { - self?.registerHotkeys() - } - } } private func triggerAction() { @@ -191,7 +213,7 @@ public final class HotkeyManager { if let tap = manager.eventTapPort { CGEvent.tapEnable(tap: tap, enable: true) } - return Unmanaged.passRetained(event) + return nil } let flags = event.flags.rawValue @@ -368,6 +390,7 @@ public final class HotkeyManager { } public func unregisterHotkeys() { + resetKeyState() if let ref = hotKeyRef { UnregisterEventHotKey(ref) hotKeyRef = nil diff --git a/src/PingMonitor.swift b/src/PingMonitor.swift index 7a4efb6..61178c4 100644 --- a/src/PingMonitor.swift +++ b/src/PingMonitor.swift @@ -28,6 +28,7 @@ public final class PingMonitor { private var session: URLSession! private var timer: Timer? private var isRunning = false + private var isMeasuring = false public var onPingUpdate: ((PingQuality) -> Void)? public private(set) var currentQuality: PingQuality = .medium(ms: 380) @@ -39,6 +40,22 @@ public final class PingMonitor { config.waitsForConnectivity = false config.requestCachePolicy = .reloadIgnoringLocalCacheData self.session = URLSession(configuration: config) + + setupSleepWakeObservers() + } + + private func setupSleepWakeObservers() { + NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] _ in + print("PingMonitor: Mac entering sleep. Pausing ping measurements.") + self?.stop() + } + + NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { [weak self] _ in + print("PingMonitor: Mac woke from sleep. Resuming ping in 2.5s after network settles...") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { + self?.start() + } + } } public func start() { @@ -55,16 +72,21 @@ public final class PingMonitor { public func stop() { isRunning = false + isMeasuring = false timer?.invalidate() timer = nil } private func measurePing() { + guard isRunning, !isMeasuring else { return } + isMeasuring = true + var req = URLRequest(url: targetURL) req.httpMethod = "HEAD" let t0 = Date() let task = session.dataTask(with: req) { [weak self] _, resp, _ in + defer { self?.isMeasuring = false } let elapsed = Date().timeIntervalSince(t0) * 1000.0 let ms = Int(elapsed) diff --git a/src/SonioxClient.swift b/src/SonioxClient.swift index dadbf91..416f61a 100644 --- a/src/SonioxClient.swift +++ b/src/SonioxClient.swift @@ -529,11 +529,37 @@ public final class SonioxSessionPool { public static let shared = SonioxSessionPool() private var prewarmedSession: SonioxLiveSession? + private var prewarmedSessionCreatedAt: Date = Date() private let lock = NSLock() private var isPrewarming = false private init() { prewarmNextSession() + setupSleepWakeObservers() + } + + private func setupSleepWakeObservers() { + NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] _ in + print("SonioxSessionPool: Mac entering sleep. Discarding pre-warmed session.") + self?.discardPrewarmedSession() + } + + NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { [weak self] _ in + print("SonioxSessionPool: Mac woke from sleep. Re-priming fresh session in 2.0s...") + self?.discardPrewarmedSession() + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self?.prewarmNextSession() + } + } + } + + public func discardPrewarmedSession() { + lock.lock() + prewarmedSession?.stopKeepAliveHeartbeat() + prewarmedSession?.cancel() + prewarmedSession = nil + isPrewarming = false + lock.unlock() } public func prewarmNextSession() { @@ -541,7 +567,10 @@ public final class SonioxSessionPool { defer { lock.unlock() } if let existing = prewarmedSession, existing.isReady { - return + let age = Date().timeIntervalSince(prewarmedSessionCreatedAt) + if age < 45.0 { + return + } } if isPrewarming { return } @@ -562,6 +591,7 @@ public final class SonioxSessionPool { self.lock.lock() session.startKeepAliveHeartbeat() self.prewarmedSession = session + self.prewarmedSessionCreatedAt = Date() self.isPrewarming = false self.lock.unlock() print("SonioxSessionPool: Fresh prewarmed session initialized & heartbeat started.") @@ -570,18 +600,14 @@ public final class SonioxSessionPool { } public func reconnectAll() { - lock.lock() - prewarmedSession?.stopKeepAliveHeartbeat() - prewarmedSession?.cancel() - prewarmedSession = nil - isPrewarming = false - lock.unlock() + discardPrewarmedSession() prewarmNextSession() } public func acquireSession() -> SonioxLiveSession { lock.lock() let session = prewarmedSession + let createdAt = prewarmedSessionCreatedAt prewarmedSession = nil lock.unlock() @@ -590,13 +616,17 @@ public final class SonioxSessionPool { self?.prewarmNextSession() } - if let session = session, session.isReady { + let age = Date().timeIntervalSince(createdAt) + if let session = session, session.isReady && age < 60.0 { session.stopKeepAliveHeartbeat() - print("SonioxSessionPool: Acquired HOT prewarmed session (0ms connect delay!)") + print("SonioxSessionPool: Acquired HOT prewarmed session (age: \(String(format: "%.1f", age))s, 0ms connect delay!)") return session } - print("SonioxSessionPool: Prewarmed session not ready yet, creating immediate on-demand session.") + session?.stopKeepAliveHeartbeat() + session?.cancel() + + print("SonioxSessionPool: Prewarmed session not ready/stale (age: \(String(format: "%.1f", age))s), creating immediate on-demand session.") let hints = SonioxSettings.shared.language.hints return SonioxLiveSession(languageHints: hints) }