227 lines
7.9 KiB
Swift
227 lines
7.9 KiB
Swift
|
|
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<AudioDeviceID>.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<Float32>.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<UInt32>.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<AudioObjectPropertyAddress>,
|
||
|
|
_ clientData: UnsafeMutableRawPointer?
|
||
|
|
) -> OSStatus {
|
||
|
|
guard let clientData = clientData else { return noErr }
|
||
|
|
let monitor = Unmanaged<VolumeMonitor>.fromOpaque(clientData).takeUnretainedValue()
|
||
|
|
monitor.handleVolumeChange()
|
||
|
|
return noErr
|
||
|
|
}
|
||
|
|
|
||
|
|
private func muteChanged(
|
||
|
|
_ objectID: AudioObjectID,
|
||
|
|
_ numberAddresses: UInt32,
|
||
|
|
_ addresses: UnsafePointer<AudioObjectPropertyAddress>,
|
||
|
|
_ clientData: UnsafeMutableRawPointer?
|
||
|
|
) -> OSStatus {
|
||
|
|
guard let clientData = clientData else { return noErr }
|
||
|
|
let monitor = Unmanaged<VolumeMonitor>.fromOpaque(clientData).takeUnretainedValue()
|
||
|
|
monitor.handleMuteChange()
|
||
|
|
return noErr
|
||
|
|
}
|
||
|
|
|
||
|
|
private func deviceChanged(
|
||
|
|
_ objectID: AudioObjectID,
|
||
|
|
_ numberAddresses: UInt32,
|
||
|
|
_ addresses: UnsafePointer<AudioObjectPropertyAddress>,
|
||
|
|
_ clientData: UnsafeMutableRawPointer?
|
||
|
|
) -> OSStatus {
|
||
|
|
guard let clientData = clientData else { return noErr }
|
||
|
|
let monitor = Unmanaged<VolumeMonitor>.fromOpaque(clientData).takeUnretainedValue()
|
||
|
|
DispatchQueue.main.async {
|
||
|
|
monitor.handleDeviceChange()
|
||
|
|
}
|
||
|
|
return noErr
|
||
|
|
}
|