import CoreAudio import AudioToolbox import Foundation class VolumeMonitor { typealias VolumeCallback = (Float, Bool) -> Void private let callback: VolumeCallback private var deviceID: AudioDeviceID = kAudioObjectUnknown private var isListening = false private var lastVolume: Float = -1 private var lastMuted: Bool = false init(callback: @escaping VolumeCallback) { self.callback = callback } func start() { guard !isListening else { return } deviceID = getDefaultOutputDevice() guard deviceID != kAudioObjectUnknown else { return } addDeviceChangeListener() addVolumeListener(for: deviceID) addMuteListener(for: deviceID) isListening = true } func stop() { guard isListening else { return } removeVolumeListener(for: deviceID) removeMuteListener(for: deviceID) removeDeviceChangeListener() isListening = false } // MARK: - Default Output Device private func getDefaultOutputDevice() -> AudioDeviceID { var deviceID = AudioDeviceID(0) var size = UInt32(MemoryLayout.size) var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) let status = AudioObjectGetPropertyData( AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID ) return status == noErr ? deviceID : kAudioObjectUnknown } // MARK: - Volume func getVolume() -> Float { guard deviceID != kAudioObjectUnknown else { return 0 } var volume: Float32 = 0 var size = UInt32(MemoryLayout.size) // Try master channel first var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyVolumeScalar, mScope: kAudioDevicePropertyScopeOutput, mElement: kAudioObjectPropertyElementMain ) var status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &volume) if status == noErr { return volume } // Fallback to channel 1 (left) address.mElement = 1 status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &volume) return status == noErr ? volume : 0 } func isMuted() -> Bool { guard deviceID != kAudioObjectUnknown else { return false } var muted: UInt32 = 0 var size = UInt32(MemoryLayout.size) var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyMute, mScope: kAudioDevicePropertyScopeOutput, mElement: kAudioObjectPropertyElementMain ) let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &muted) return status == noErr ? muted != 0 : false } // MARK: - Listeners private func addVolumeListener(for device: AudioDeviceID) { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyVolumeScalar, mScope: kAudioDevicePropertyScopeOutput, mElement: kAudioObjectPropertyElementMain ) // Try master first let status = AudioObjectAddPropertyListener(device, &address, volumeChanged, Unmanaged.passUnretained(self).toOpaque()) if status != noErr { // Fallback: listen on channel 1 address.mElement = 1 AudioObjectAddPropertyListener(device, &address, volumeChanged, Unmanaged.passUnretained(self).toOpaque()) } } private func removeVolumeListener(for device: AudioDeviceID) { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyVolumeScalar, mScope: kAudioDevicePropertyScopeOutput, mElement: kAudioObjectPropertyElementMain ) AudioObjectRemovePropertyListener(device, &address, volumeChanged, Unmanaged.passUnretained(self).toOpaque()) address.mElement = 1 AudioObjectRemovePropertyListener(device, &address, volumeChanged, Unmanaged.passUnretained(self).toOpaque()) } private func addMuteListener(for device: AudioDeviceID) { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyMute, mScope: kAudioDevicePropertyScopeOutput, mElement: kAudioObjectPropertyElementMain ) AudioObjectAddPropertyListener(device, &address, muteChanged, Unmanaged.passUnretained(self).toOpaque()) } private func removeMuteListener(for device: AudioDeviceID) { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyMute, mScope: kAudioDevicePropertyScopeOutput, mElement: kAudioObjectPropertyElementMain ) AudioObjectRemovePropertyListener(device, &address, muteChanged, Unmanaged.passUnretained(self).toOpaque()) } private func addDeviceChangeListener() { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) AudioObjectAddPropertyListener( AudioObjectID(kAudioObjectSystemObject), &address, deviceChanged, Unmanaged.passUnretained(self).toOpaque() ) } private func removeDeviceChangeListener() { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) AudioObjectRemovePropertyListener( AudioObjectID(kAudioObjectSystemObject), &address, deviceChanged, Unmanaged.passUnretained(self).toOpaque() ) } // MARK: - Callbacks fileprivate func handleVolumeChange() { let vol = getVolume() let muted = isMuted() callback(vol, muted) } fileprivate func handleMuteChange() { let vol = getVolume() let muted = isMuted() callback(vol, muted) } fileprivate func handleDeviceChange() { // Remove old listeners if deviceID != kAudioObjectUnknown { removeVolumeListener(for: deviceID) removeMuteListener(for: deviceID) } // Switch to new device deviceID = getDefaultOutputDevice() if deviceID != kAudioObjectUnknown { addVolumeListener(for: deviceID) addMuteListener(for: deviceID) } } } // C-style callbacks that bridge to the class methods private func volumeChanged( _ objectID: AudioObjectID, _ numberAddresses: UInt32, _ addresses: UnsafePointer, _ clientData: UnsafeMutableRawPointer? ) -> OSStatus { guard let clientData = clientData else { return noErr } let monitor = Unmanaged.fromOpaque(clientData).takeUnretainedValue() monitor.handleVolumeChange() return noErr } private func muteChanged( _ objectID: AudioObjectID, _ numberAddresses: UInt32, _ addresses: UnsafePointer, _ clientData: UnsafeMutableRawPointer? ) -> OSStatus { guard let clientData = clientData else { return noErr } let monitor = Unmanaged.fromOpaque(clientData).takeUnretainedValue() monitor.handleMuteChange() return noErr } private func deviceChanged( _ objectID: AudioObjectID, _ numberAddresses: UInt32, _ addresses: UnsafePointer, _ clientData: UnsafeMutableRawPointer? ) -> OSStatus { guard let clientData = clientData else { return noErr } let monitor = Unmanaged.fromOpaque(clientData).takeUnretainedValue() DispatchQueue.main.async { monitor.handleDeviceChange() } return noErr }