import Foundation import CoreAudio import OSLog private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio") /// Ein Ein- oder Ausgabegerät. public struct AudioDevice: Identifiable, Equatable, Sendable { public let id: AudioObjectID public let name: String public let isOutput: Bool public let isInput: Bool } /// Die Systemlautstärken und die Gerätewahl. /// /// Getrennt vom Mischpult der einzelnen Programme, weil es technisch etwas /// ganz anderes ist: hier wird kein Ton abgegriffen, hier werden die Regler /// des Systems bedient. Das geht ohne Tap, ohne Aggregate-Gerät und ohne /// Berechtigung — und funktioniert deshalb auch, wenn der Mixer aus ist. @MainActor @Observable public final class SystemAudio { public private(set) var outputDevices: [AudioDevice] = [] public private(set) var inputDevices: [AudioDevice] = [] public private(set) var outputDeviceID = AudioObjectID(kAudioObjectUnknown) public private(set) var inputDeviceID = AudioObjectID(kAudioObjectUnknown) public private(set) var outputVolume: Double = 0 public private(set) var outputMuted = false public private(set) var inputVolume: Double = 0 public private(set) var inputMuted = false /// Warnton-Lautstärke. `nil`, wenn das Gerät sie nicht getrennt führt. public private(set) var alertVolume: Double? /// Ob sich die Lautstärke überhaupt setzen lässt. /// /// Manche Geräte — HDMI, viele USB-Interfaces — bringen keinen eigenen /// Regler mit; dort regelt man am Gerät. Ein Regler, der nichts tut, wäre /// eine Pseudobedienung. public private(set) var outputIsAdjustable = false public private(set) var inputIsAdjustable = false private var listeners: [PropertyListener] = [] public init() { refresh() observeSystemChanges() } // MARK: - Lesen public func refresh() { outputDevices = Self.devices().filter(\.isOutput) inputDevices = Self.devices().filter(\.isInput) outputDeviceID = Self.defaultDevice(kAudioHardwarePropertyDefaultOutputDevice) inputDeviceID = Self.defaultDevice(kAudioHardwarePropertyDefaultInputDevice) outputVolume = Self.volume(outputDeviceID, scope: kAudioDevicePropertyScopeOutput) ?? 0 outputIsAdjustable = Self.volume(outputDeviceID, scope: kAudioDevicePropertyScopeOutput) != nil outputMuted = Self.muted(outputDeviceID, scope: kAudioDevicePropertyScopeOutput) inputVolume = Self.volume(inputDeviceID, scope: kAudioDevicePropertyScopeInput) ?? 0 inputIsAdjustable = Self.volume(inputDeviceID, scope: kAudioDevicePropertyScopeInput) != nil inputMuted = Self.muted(inputDeviceID, scope: kAudioDevicePropertyScopeInput) let alertDevice = Self.defaultDevice(kAudioHardwarePropertyDefaultSystemOutputDevice) alertVolume = alertDevice == outputDeviceID ? nil // Dasselbe Gerät, derselbe Regler — keine zweite Zeile dafür. : Self.volume(alertDevice, scope: kAudioDevicePropertyScopeOutput) } // MARK: - Setzen public func setOutputVolume(_ value: Double) { Self.setVolume(value, on: outputDeviceID, scope: kAudioDevicePropertyScopeOutput) outputVolume = min(max(value, 0), 1) // Lautstärke ziehen hebt die Stummschaltung auf — sonst zieht man am // Regler und hört weiter nichts. if outputMuted, value > 0 { setOutputMuted(false) } } public func setOutputMuted(_ muted: Bool) { Self.setMuted(muted, on: outputDeviceID, scope: kAudioDevicePropertyScopeOutput) outputMuted = muted } public func setInputVolume(_ value: Double) { Self.setVolume(value, on: inputDeviceID, scope: kAudioDevicePropertyScopeInput) inputVolume = min(max(value, 0), 1) if inputMuted, value > 0 { setInputMuted(false) } } public func setInputMuted(_ muted: Bool) { Self.setMuted(muted, on: inputDeviceID, scope: kAudioDevicePropertyScopeInput) inputMuted = muted } public func selectOutput(_ device: AudioDevice) { Self.setDefaultDevice(device.id, selector: kAudioHardwarePropertyDefaultOutputDevice) // Warntöne folgen dem Ausgabegerät: zwei getrennte Wahlen sind eine // Quelle für „warum kommt der Systemton aus dem Notebook". Self.setDefaultDevice(device.id, selector: kAudioHardwarePropertyDefaultSystemOutputDevice) refresh() } public func selectInput(_ device: AudioDevice) { Self.setDefaultDevice(device.id, selector: kAudioHardwarePropertyDefaultInputDevice) refresh() } // MARK: - Core Audio /// Gerätewechsel kommen von außen: Kopfhörer einstecken, Bluetooth /// verbinden, ein anderes Programm stellt das Standardgerät um. private func observeSystemChanges() { listeners = [kAudioHardwarePropertyDefaultOutputDevice, kAudioHardwarePropertyDefaultInputDevice, kAudioHardwarePropertyDevices].compactMap { selector in PropertyListener(selector: selector) { [weak self] in Task { @MainActor in self?.refresh() } } } } private static func devices() -> [AudioDevice] { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var size: UInt32 = 0 guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size) == noErr else { return [] } var ids = [AudioObjectID](repeating: 0, count: Int(size) / MemoryLayout.size) guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &ids) == noErr else { return [] } return ids.compactMap { id in // Die eigenen Aggregate-Geräte gehören nicht in die Auswahl: sie // sind Werkzeug, kein Ziel. Wer eines davon als Ausgabe wählt, // hängt seinen Ton in eine Schleife. guard !isOwnDevice(uid: uid(of: id)) else { return nil } guard let name = name(of: id) else { return nil } let output = channels(id, scope: kAudioDevicePropertyScopeOutput) > 0 let input = channels(id, scope: kAudioDevicePropertyScopeInput) > 0 // Geräte ohne Kanäle sind Aggregate-Hüllen und Ähnliches — sie in // die Auswahl zu stellen hilft niemandem. guard output || input else { return nil } return AudioDevice(id: id, name: name, isOutput: output, isInput: input) } } /// Ob Onyx dieses Gerät selbst angelegt hat. /// /// `nonisolated`, weil es eine reine Zeichenkettenprüfung ist und keinen /// Zustand anfasst — so lässt es sich auch prüfen, ohne den MainActor zu /// bemühen. nonisolated static func isOwnDevice(uid: String?) -> Bool { guard let uid, !uid.isEmpty else { return false } return uid.hasPrefix(ProcessTap.aggregateUIDPrefix) } private static func uid(of device: AudioObjectID) -> String? { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyDeviceUID, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var size = UInt32(MemoryLayout.size) var uid: CFString? guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &uid) == noErr else { return nil } return uid as String? } private static func name(of device: AudioObjectID) -> String? { var address = AudioObjectPropertyAddress( mSelector: kAudioObjectPropertyName, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var size = UInt32(MemoryLayout.size) var name: CFString? guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &name) == noErr else { return nil } return name as String? } private static func channels(_ device: AudioObjectID, scope: AudioObjectPropertyScope) -> Int { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyStreamConfiguration, mScope: scope, mElement: kAudioObjectPropertyElementMain) var size: UInt32 = 0 guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr, size > 0 else { return 0 } let buffer = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: MemoryLayout.alignment) defer { buffer.deallocate() } guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, buffer) == noErr else { return 0 } let list = UnsafeMutableAudioBufferListPointer( buffer.assumingMemoryBound(to: AudioBufferList.self)) return list.reduce(0) { $0 + Int($1.mNumberChannels) } } private static func defaultDevice(_ selector: AudioObjectPropertySelector) -> AudioObjectID { var address = AudioObjectPropertyAddress( mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var device = AudioObjectID(kAudioObjectUnknown) var size = UInt32(MemoryLayout.size) AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &device) return device } private static func setDefaultDevice(_ device: AudioObjectID, selector: AudioObjectPropertySelector) { var address = AudioObjectPropertyAddress( mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var value = device let status = AudioObjectSetPropertyData( AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, UInt32(MemoryLayout.size), &value) if status != noErr { log.error("Ausgabegerät nicht gesetzt: \(status, privacy: .public)") } } /// Die Lautstärke eines Geräts. /// /// **Zuerst der Hauptregler, dann die Kanäle.** Viele Geräte führen keinen /// Regler für Element 0, sondern nur je Kanal — fragt man nur den /// Hauptregler, hält man ein regelbares Gerät für unregelbar. private static func volume(_ device: AudioObjectID, scope: AudioObjectPropertyScope) -> Double? { guard device != AudioObjectID(kAudioObjectUnknown) else { return nil } for element in [kAudioObjectPropertyElementMain, 1, 2] { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyVolumeScalar, mScope: scope, mElement: AudioObjectPropertyElement(element)) guard AudioObjectHasProperty(device, &address) else { continue } var value: Float32 = 0 var size = UInt32(MemoryLayout.size) guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) == noErr else { continue } return Double(value) } return nil } private static func setVolume(_ value: Double, on device: AudioObjectID, scope: AudioObjectPropertyScope) { guard device != AudioObjectID(kAudioObjectUnknown) else { return } var scalar = Float32(min(max(value, 0), 1)) for element in [kAudioObjectPropertyElementMain, 1, 2] { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyVolumeScalar, mScope: scope, mElement: AudioObjectPropertyElement(element)) guard AudioObjectHasProperty(device, &address) else { continue } var settable: DarwinBoolean = false guard AudioObjectIsPropertySettable(device, &address, &settable) == noErr, settable.boolValue else { continue } AudioObjectSetPropertyData(device, &address, 0, nil, UInt32(MemoryLayout.size), &scalar) // Beim Hauptregler ist man fertig; bei Kanälen müssen beide gesetzt // werden, sonst wandert das Stereobild. if element == kAudioObjectPropertyElementMain { return } } } private static func muted(_ device: AudioObjectID, scope: AudioObjectPropertyScope) -> Bool { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyMute, mScope: scope, mElement: kAudioObjectPropertyElementMain) guard AudioObjectHasProperty(device, &address) else { return false } var value: UInt32 = 0 var size = UInt32(MemoryLayout.size) guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) == noErr else { return false } return value != 0 } private static func setMuted(_ muted: Bool, on device: AudioObjectID, scope: AudioObjectPropertyScope) { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyMute, mScope: scope, mElement: kAudioObjectPropertyElementMain) guard AudioObjectHasProperty(device, &address) else { return } var value: UInt32 = muted ? 1 : 0 AudioObjectSetPropertyData(device, &address, 0, nil, UInt32(MemoryLayout.size), &value) } } /// Hält eine Core-Audio-Anmeldung und meldet sie beim Abräumen wieder ab. /// /// Wie beim Kalender ein eigenes Objekt: `SystemAudio` liegt auf dem /// MainActor, sein `deinit` aber nicht — es dürfte die Anmeldung dort gar /// nicht mehr anfassen. Und `AudioObjectRemovePropertyListenerBlock` will /// **denselben** Block zurück, den man angemeldet hat; der muss also /// irgendwo aufgehoben werden. /// /// `@unchecked Sendable` ist belegbar: nach `init` wird hier nichts mehr /// geschrieben. private final class PropertyListener: @unchecked Sendable { private let object = AudioObjectID(kAudioObjectSystemObject) private var address: AudioObjectPropertyAddress private let block: AudioObjectPropertyListenerBlock init?(selector: AudioObjectPropertySelector, handler: @escaping @Sendable () -> Void) { address = AudioObjectPropertyAddress( mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) block = { _, _ in handler() } guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr else { return nil } } deinit { AudioObjectRemovePropertyListenerBlock(object, &address, .main, block) } }