diff --git a/packages/host/Sources/SoundBridgeHost/Devices/DeviceMonitor.swift b/packages/host/Sources/SoundBridgeHost/Devices/DeviceMonitor.swift index 9084f83..0694011 100644 --- a/packages/host/Sources/SoundBridgeHost/Devices/DeviceMonitor.swift +++ b/packages/host/Sources/SoundBridgeHost/Devices/DeviceMonitor.swift @@ -4,6 +4,13 @@ import os.log private let logger = Logger(subsystem: "com.soundbridge.host", category: "DeviceMonitor") +enum HostState: String { + case starting + case idle + case active + case stopping +} + class DeviceMonitor { private let registry: DeviceRegistry private let proxyManager: ProxyDeviceManager @@ -16,6 +23,7 @@ class DeviceMonitor { private var listenersRegistered = false private var devicesListenerRegistered = false private var defaultOutputListenerRegistered = false + private(set) var hostState: HostState = .starting init( registry: DeviceRegistry, @@ -31,6 +39,13 @@ class DeviceMonitor { self.audioEngine = audioEngine } + func setHostState(_ newState: HostState) { + guard hostState != newState else { return } + + print("[HostState] \(hostState.rawValue) -> \(newState.rawValue)") + hostState = newState + } + func registerListeners() { guard !listenersRegistered else { return } @@ -136,6 +151,23 @@ class DeviceMonitor { !newDevices.contains { $0.uid == old.uid } } + let startingFromIdle = hostState == .idle && !newDevices.isEmpty + let enteringIdle = hostState == .active && newDevices.isEmpty + + if startingFromIdle { + let preferredDevice = + proxyManager.resolveCurrentOutputDevice(in: newDevices) + ?? newDevices.first! + + let sampleRate = discovery.getDeviceNominalSampleRate(preferredDevice.id) + SoundBridgeConfig.activeSampleRate = sampleRate + + print( + "[DeviceMonitor] Leaving idle mode: \(sampleRate) Hz " + + "(from \(preferredDevice.name))" + ) + } + // 1. Create shared memory for new devices for device in addedDevices { print("Device added: \(device.name) (\(discovery.transportTypeName(device.transportType)))") @@ -149,6 +181,37 @@ class DeviceMonitor { // 2. Update registry (writes control file + sends Darwin notification) registry.update(newDevices) + if enteringIdle { + print("[DeviceMonitor] No output devices remain; entering idle mode") + + audioEngine.stop() + memoryManager.stopHeartbeat() + setHostState(.idle) + } + + if startingFromIdle { + memoryManager.startHeartbeat() + + do { + let preferredDevice = + proxyManager.resolveCurrentOutputDevice(in: newDevices) + ?? newDevices.first! + + try audioEngine.setup( + devices: newDevices, + preferredDeviceID: preferredDevice.id + ) + try audioEngine.start() + + setHostState(.active) + logger.info("Audio engine started after leaving idle mode") + } catch { + logger.error( + "Failed to start audio engine after leaving idle mode: \(error.localizedDescription)" + ) + } + } + // 3. Delay shared memory removal for removed devices if !removedDevices.isEmpty { let removedUIDs = removedDevices.map { $0.uid } @@ -311,8 +374,15 @@ private func deviceListChangedCallbackC( _ clientData: UnsafeMutableRawPointer? ) -> OSStatus { guard let clientData else { return noErr } - Unmanaged.fromOpaque(clientData).takeUnretainedValue() - .handleDeviceListChanged() + + let monitor = Unmanaged + .fromOpaque(clientData) + .takeUnretainedValue() + + DispatchQueue.main.async { + monitor.handleDeviceListChanged() + } + return noErr } @@ -323,7 +393,14 @@ private func defaultOutputChangedCallbackC( _ clientData: UnsafeMutableRawPointer? ) -> OSStatus { guard let clientData else { return noErr } - Unmanaged.fromOpaque(clientData).takeUnretainedValue() - .handleDefaultOutputChanged() + + let monitor = Unmanaged + .fromOpaque(clientData) + .takeUnretainedValue() + + DispatchQueue.main.async { + monitor.handleDefaultOutputChanged() + } + return noErr } diff --git a/packages/host/Sources/SoundBridgeHost/main.swift b/packages/host/Sources/SoundBridgeHost/main.swift index 2cd5e5b..caaab3b 100644 --- a/packages/host/Sources/SoundBridgeHost/main.swift +++ b/packages/host/Sources/SoundBridgeHost/main.swift @@ -58,12 +58,25 @@ func main() { exit(1) } + setupSignalHandlers() + logger.info("Signal handlers installed") + + setupSleepWakeMonitoring() + print("[Step 1] Discovering physical audio devices...") let devices = deviceDiscovery.enumeratePhysicalDevices() - guard !devices.isEmpty else { - logger.error("No physical output devices found") - exit(1) + if devices.isEmpty { + logger.info("No physical output devices currently available; entering idle mode") + deviceMonitor.setHostState(.idle) + } + + print("[Step 2] Registering device change listeners...") + deviceMonitor.registerListeners() + + if devices.isEmpty { + RunLoop.current.run() + return } let validatedDevices = devices.filter { $0.validationPassed } @@ -91,9 +104,6 @@ func main() { deviceRegistry.update(devices) - print("[Step 2] Registering device change listeners...") - deviceMonitor.registerListeners() - print("[Step 3] Creating shared memory files...") memoryManager.createMemory(for: devices) @@ -117,31 +127,6 @@ func main() { } } - print("[Step 7.5] Registering sleep/wake handler...") - sleepWakeMonitor.onSleep = { - print("[SleepWake] System entering sleep, stopping AudioEngine...") - audioEngine.stop() - logger.info("AudioEngine stopped before sleep") - } - sleepWakeMonitor.onWake = { - print("[SleepWake] Recovering after wake...") - deviceMonitor.reregisterListeners() - deviceMonitor.resetDebounce() - proxyManager.reregisterVolumeForwarding() - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - do { - try audioEngine.setup(devices: deviceRegistry.devices, - preferredDeviceID: proxyManager.activePhysicalDeviceID) - try audioEngine.start() - logger.info("AudioEngine restarted after wake") - } catch { - logger.error("AudioEngine restart failed: \(error.localizedDescription)") - } - } - } - sleepWakeMonitor.start() - print("[Step 8] Setting up audio engine with device fallback...") // Get the user's preferred device from proxy manager (set during autoSelectProxy) @@ -157,6 +142,8 @@ func main() { do { try audioEngine.setup(devices: devices, preferredDeviceID: preferredDeviceID != 0 ? preferredDeviceID : nil) try audioEngine.start() + + deviceMonitor.setHostState(.active) logger.info("Audio engine started successfully") // Post-start volume sync: After IO starts, the driver maps shared memory. @@ -186,10 +173,6 @@ func main() { exit(1) } - setupSignalHandlers() - - logger.info("Signal handlers installed") - // Listen for bounce requests from App via Darwin notification var bounceToken: Int32 = 0 let bounceStatus = _notify_register_dispatch( @@ -207,6 +190,51 @@ func main() { RunLoop.current.run() } +func setupSleepWakeMonitoring() { + print("[Startup] Registering sleep/wake handler...") + + sleepWakeMonitor.onSleep = { + guard deviceMonitor.hostState == .active else { + logger.info("Host is idle; no AudioEngine to stop before sleep") + return + } + + print("[SleepWake] System entering sleep, stopping AudioEngine...") + audioEngine.stop() + logger.info("AudioEngine stopped before sleep") + } + + sleepWakeMonitor.onWake = { + print("[SleepWake] Recovering after wake...") + + deviceMonitor.reregisterListeners() + deviceMonitor.resetDebounce() + proxyManager.reregisterVolumeForwarding() + + guard deviceMonitor.hostState == .active else { + logger.info("Host is idle after wake; skipping AudioEngine restart") + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + do { + try audioEngine.setup( + devices: deviceRegistry.devices, + preferredDeviceID: proxyManager.activePhysicalDeviceID + ) + try audioEngine.start() + logger.info("AudioEngine restarted after wake") + } catch { + logger.error( + "AudioEngine restart failed: \(error.localizedDescription)" + ) + } + } + } + + sleepWakeMonitor.start() +} + func setupSignalHandlers() { let sigintSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) sigintSource.setEventHandler { @@ -229,6 +257,7 @@ func setupSignalHandlers() { } func cleanup() { + deviceMonitor.setHostState(.stopping) print("\n[Cleanup] Starting cleanup process...") sleepWakeMonitor.stop()