From e1ce9b9b33b4120004c096a6a766024e980effff Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Sat, 26 Sep 2026 05:18:37 +0000 Subject: [PATCH] fix: stop mic hardware immediately when idle, make HUD always on top, and dual-layer hotkey listener --- src/AppDelegate.swift | 13 ++++++-- src/AudioRecorder.swift | 19 ++++++----- src/HUDOverlay.swift | 22 ++++++------ src/HotkeyManager.swift | 74 +++++++++++++++++++++++++++++++++++++---- 4 files changed, 101 insertions(+), 27 deletions(-) diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index c9d0374..e390152 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -82,8 +82,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { // Pre-warm the background WebSocket session for 0ms startup delay SonioxSessionPool.shared.prewarmNextSession() - // Pre-warm audio hardware capture pipeline for instant 0ms recording - try? audioRecorder.ensureSessionRunning() + // Pre-configure audio hardware capture graph (kept STOPPED until key is pressed) + try? audioRecorder.ensureSessionConfigured() // Check permissions on start checkInitialPermissions() @@ -119,7 +119,11 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { } public func startRecording() { - if audioRecorder.isRecording || isBusyFinalizing { return } + if audioRecorder.isRecording { return } + if isBusyFinalizing { + print("AppDelegate: isBusyFinalizing was active when startRecording invoked. Clearing stale lock.") + isBusyFinalizing = false + } isNetworkDegraded = false lastRecordedAudio.removeAll() @@ -219,6 +223,9 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in 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() } diff --git a/src/AudioRecorder.swift b/src/AudioRecorder.swift index ba57042..7fb89aa 100644 --- a/src/AudioRecorder.swift +++ b/src/AudioRecorder.swift @@ -58,8 +58,8 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer return AVCaptureDevice.default(for: .audio) ?? all.first } - public func ensureSessionRunning() throws { - if let session = captureSession, session.isRunning { + public func ensureSessionConfigured() throws { + if captureSession != nil { return } guard let device = resolveCaptureDevice() else { @@ -77,8 +77,7 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer } self.captureSession = session self.audioOutput = output - session.startRunning() - print("AudioRecorder: Pre-warmed audio session primed with device:", device.localizedName) + print("AudioRecorder: Capture session graph configured for device:", device.localizedName) } public func startRecording() throws { @@ -88,9 +87,12 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer if isRecording { return } pcmBuffer.removeAll() - try ensureSessionRunning() + try ensureSessionConfigured() + if let session = captureSession, !session.isRunning { + session.startRunning() + } isRecording = true - print("AudioRecorder: Instant 0ms recording started.") + print("AudioRecorder: Fast recording started (mic hardware ON).") } public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { @@ -178,8 +180,9 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer if !isRecording { return pcmBuffer } isRecording = false - // Keep captureSession running so subsequent presses start in 0ms! - print("AudioRecorder: Stopped. Total PCM captured: \(pcmBuffer.count) bytes (\(Double(pcmBuffer.count)/32000.0) seconds). Audio engine kept hot.") + // Immediately stop hardware capture so macOS orange mic indicator turns OFF + captureSession?.stopRunning() + print("AudioRecorder: Stopped. Microphone hardware OFF. Total PCM captured: \(pcmBuffer.count) bytes.") return pcmBuffer } } diff --git a/src/HUDOverlay.swift b/src/HUDOverlay.swift index 61e23bf..1002329 100644 --- a/src/HUDOverlay.swift +++ b/src/HUDOverlay.swift @@ -35,7 +35,7 @@ public final class HUDOverlayController { ) panel.isOpaque = false panel.backgroundColor = .clear - panel.level = .floating + panel.level = .statusBar panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] panel.hasShadow = false panel.ignoresMouseEvents = true @@ -54,7 +54,7 @@ public final class HUDOverlayController { } private func reposition() { - guard let window = self.window, let screen = NSScreen.main else { return } + guard let window = self.window, let screen = NSScreen.main ?? NSScreen.screens.first else { return } let screenRect = screen.visibleFrame // Center horizontally, positioned comfortably higher above dock let x = screenRect.midX - (canvasSize / 2.0) @@ -76,17 +76,17 @@ public final class HUDOverlayController { self.hide() case .recording(let level, let isLagging, let pingQuality): - if !self.isVisible { + if !self.isVisible || window.alphaValue < 0.95 { window.orderFrontRegardless() - window.animator().alphaValue = 1.0 + window.alphaValue = 1.0 self.isVisible = true } orb.updateAudioLevel(level, isLagging: isLagging, pingQuality: pingQuality) case .retrying: - if !self.isVisible { + if !self.isVisible || window.alphaValue < 0.95 { window.orderFrontRegardless() - window.animator().alphaValue = 1.0 + window.alphaValue = 1.0 self.isVisible = true } orb.showRetryState() @@ -112,13 +112,15 @@ public final class HUDOverlayController { public func hide() { DispatchQueue.main.async { [weak self] in guard let self = self, let window = self.window, self.isVisible else { return } + self.isVisible = false NSAnimationContext.runAnimationGroup({ context in - context.duration = 0.18 + context.duration = 0.15 window.animator().alphaValue = 0.0 }, completionHandler: { - window.orderOut(nil) - self.isVisible = false - self.orbView?.reset() + if !self.isVisible { + window.orderOut(nil) + self.orbView?.reset() + } }) } } diff --git a/src/HotkeyManager.swift b/src/HotkeyManager.swift index 1807e67..4e8db81 100644 --- a/src/HotkeyManager.swift +++ b/src/HotkeyManager.swift @@ -148,13 +148,29 @@ public final class HotkeyManager { // 2. Global Event Tap for high-priority interception (Microphone Key, Option, CapsLock, Fn/Globe) setupEventTap() - // 3. Fallback NSEvent monitor + // 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() { let now = Date().timeIntervalSince1970 - guard (now - lastTriggerTime) > 0.25 else { return } + guard (now - lastTriggerTime) > 0.10 else { return } lastTriggerTime = now DispatchQueue.main.async { [weak self] in self?.onHotkeyPressed?() @@ -169,6 +185,15 @@ public final class HotkeyManager { guard let refcon = refcon else { return Unmanaged.passRetained(event) } let manager = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + // Auto-recover if macOS disabled tap on timeout or user input + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + print("HotkeyManager: EventTap was disabled by OS (\(type.rawValue)). Re-enabling now...") + if let tap = manager.eventTapPort { + CGEvent.tapEnable(tap: tap, enable: true) + } + return Unmanaged.passRetained(event) + } + let flags = event.flags.rawValue let keyCode = event.getIntegerValueField(.keyboardEventKeycode) @@ -294,13 +319,50 @@ public final class HotkeyManager { } private func setupGlobalMonitor() { - globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in - guard let self = self, self.eventTapPort == nil else { return } + if let mon = globalMonitor { + NSEvent.removeMonitor(mon) + self.globalMonitor = nil + } + + // Active backup monitor that never dies on sleep/wake + globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged, .keyDown, .keyUp]) { [weak self] event in + guard let self = self else { return } - if self.currentPreset == .controlSpace { - if event.keyCode == 49 && event.modifierFlags.contains(.control) { + let flags = event.modifierFlags + let keyCode = event.keyCode + + if self.currentPreset == .option { + if keyCode == 58 || keyCode == 61 { // Left or Right Option + let isAlt = flags.contains(.option) + if isAlt != self.isOptionPhysicallyDown { + self.isOptionPhysicallyDown = isAlt + if isAlt { + self.triggerAction() + } else if self.currentMode == .pushToTalk { + DispatchQueue.main.async { + self.onHotkeyReleased?() + } + } + } + } + } else if self.currentPreset == .controlSpace { + if keyCode == 49 && flags.contains(.control) { self.triggerAction() } + } else if self.currentPreset == .capsLock { + if keyCode == 57 { + let isCaps = flags.contains(.capsLock) + if isCaps != self.isCapsLockPhysicallyDown { + self.isCapsLockPhysicallyDown = isCaps + if isCaps { + self.triggerAction() + } else if self.currentMode == .pushToTalk { + DispatchQueue.main.async { + self.onHotkeyReleased?() + } + } + } + } } } }