hudini/Sources/HUDini/VolumeMonitor.swift
johnfrum1234 cee343dd96 Initial commit: HUDini — classic volume HUD for macOS
Menu bar utility that shows a centered HUD overlay with volume bars
when system volume changes. CoreAudio monitoring, auto-fade, works
on all spaces and fullscreen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:28:41 +02:00

226 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
}