Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 81 additions & 4 deletions packages/host/Sources/SoundBridgeHost/Devices/DeviceMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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 }

Expand Down Expand Up @@ -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)))")
Expand All @@ -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 }
Expand Down Expand Up @@ -311,8 +374,15 @@ private func deviceListChangedCallbackC(
_ clientData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let clientData else { return noErr }
Unmanaged<DeviceMonitor>.fromOpaque(clientData).takeUnretainedValue()
.handleDeviceListChanged()

let monitor = Unmanaged<DeviceMonitor>
.fromOpaque(clientData)
.takeUnretainedValue()

DispatchQueue.main.async {
monitor.handleDeviceListChanged()
}

return noErr
}

Expand All @@ -323,7 +393,14 @@ private func defaultOutputChangedCallbackC(
_ clientData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let clientData else { return noErr }
Unmanaged<DeviceMonitor>.fromOpaque(clientData).takeUnretainedValue()
.handleDefaultOutputChanged()

let monitor = Unmanaged<DeviceMonitor>
.fromOpaque(clientData)
.takeUnretainedValue()

DispatchQueue.main.async {
monitor.handleDefaultOutputChanged()
}

return noErr
}
99 changes: 64 additions & 35 deletions packages/host/Sources/SoundBridgeHost/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand All @@ -229,6 +257,7 @@ func setupSignalHandlers() {
}

func cleanup() {
deviceMonitor.setHostState(.stopping)
print("\n[Cleanup] Starting cleanup process...")

sleepWakeMonitor.stop()
Expand Down