import CoreAudio import Foundation /// Watches whether *any* app is using the default input device (the system-wide /// "mic is live" signal), via CoreAudio property listeners. Re-binds when the /// default input device changes (e.g. you plug in a headset mid-call). /// /// Threading: ALL CoreAudio state (deviceID, listener blocks, `started`) and all /// Add/Remove calls are confined to the serial `queue`. `isRunning` is written /// and read only on the main thread (via `deliver`). `onChange` fires on main. final class MicActivityMonitor { private(set) var isRunning = false // main-thread only var onChange: ((Bool) -> Void)? private let queue = DispatchQueue(label: "xyz.ten31.micmonitor") // queue-confined: private var deviceID = AudioObjectID(kAudioObjectUnknown) private var runningBlock: AudioObjectPropertyListenerBlock? private var defaultDeviceBlock: AudioObjectPropertyListenerBlock? private var started = false private static let runningAddr = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) private static let defaultDeviceAddr = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultInputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) func start() { queue.async { self.begin() } } /// Called on the main thread (by the @MainActor CallDetector). Resets /// `isRunning` so a subsequent enable()'s synchronous evaluation can't read a /// stale `true` before the fresh reading arrives. func stop() { queue.sync { self.end() } isRunning = false } // MARK: - queue-confined private func begin() { guard !started else { return } started = true var addr = Self.defaultDeviceAddr let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in self?.rebindRunning() // delivered on `queue` } defaultDeviceBlock = block AudioObjectAddPropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &addr, queue, block) bindRunning() } private func end() { started = false if let block = defaultDeviceBlock { var addr = Self.defaultDeviceAddr AudioObjectRemovePropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &addr, queue, block) defaultDeviceBlock = nil } unbindRunning() } private func bindRunning() { guard started else { return } deviceID = Self.defaultInputDevice() guard deviceID != AudioObjectID(kAudioObjectUnknown) else { return } var addr = Self.runningAddr let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in guard let self else { return } self.deliver(Self.isDeviceRunning(self.deviceID)) // on `queue` } runningBlock = block // Install the listener BEFORE the initial read so a flip during setup is // caught (either by the now-installed listener or the post-install read). AudioObjectAddPropertyListenerBlock(deviceID, &addr, queue, block) deliver(Self.isDeviceRunning(deviceID)) } private func unbindRunning() { if deviceID != AudioObjectID(kAudioObjectUnknown), let block = runningBlock { var addr = Self.runningAddr AudioObjectRemovePropertyListenerBlock(deviceID, &addr, queue, block) } runningBlock = nil deviceID = AudioObjectID(kAudioObjectUnknown) } private func rebindRunning() { guard started else { return } unbindRunning() bindRunning() } private func deliver(_ running: Bool) { DispatchQueue.main.async { let changed = running != self.isRunning self.isRunning = running if changed { self.onChange?(running) } } } // MARK: - CoreAudio reads (use local address copies) private static func defaultInputDevice() -> AudioObjectID { var addr = defaultDeviceAddr var device = AudioObjectID(kAudioObjectUnknown) var size = UInt32(MemoryLayout.size) let status = AudioObjectGetPropertyData( AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &device) return status == noErr ? device : AudioObjectID(kAudioObjectUnknown) } private static func isDeviceRunning(_ device: AudioObjectID) -> Bool { guard device != AudioObjectID(kAudioObjectUnknown) else { return false } var addr = runningAddr var value: UInt32 = 0 var size = UInt32(MemoryLayout.size) let status = AudioObjectGetPropertyData(device, &addr, 0, nil, &size, &value) return status == noErr && value != 0 } }