Browse Source

fix(sleep-wake): complete sleep/wake lifecycle management for AudioSession, WebSocket pool, Hotkey event tap, and PingMonitor

main
Ali Alavi 2 weeks ago
parent
commit
2778cf29f8
  1. 3
      src/AppDelegate.swift
  2. 41
      src/AudioRecorder.swift
  3. 3
      src/HUDOverlay.swift
  4. 63
      src/HotkeyManager.swift
  5. 22
      src/PingMonitor.swift
  6. 48
      src/SonioxClient.swift

3
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()

41
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) {

3
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()
}
}
}

63
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

22
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)

48
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,8 +567,11 @@ public final class SonioxSessionPool {
defer { lock.unlock() }
if let existing = prewarmedSession, existing.isReady {
let age = Date().timeIntervalSince(prewarmedSessionCreatedAt)
if age < 45.0 {
return
}
}
if isPrewarming { return }
isPrewarming = true
@ -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)
}

Loading…
Cancel
Save