Files
onyx/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift
Scarriffle bf799d57d9 Systemlautstärke und Gerätewahl im Mixer
Ausgabe und Eingabe stehen jetzt über den Programmen — mit Lautstärke,
Stummschalter und der Gerätewahl direkt am Namen. Ein eigenes Auswahlfeld
daneben wäre eine zweite Stelle für dieselbe Sache.

Sie stehen **außerhalb** des Mixer-Schalters, und das ist der Punkt: hier
wird kein Ton abgegriffen, sondern es werden die Regler des Systems bedient.
Das braucht keinen Tap, kein Aggregate-Gerät und keine Berechtigung — wer
nur die Lautstärke sucht, soll dafür nichts einschalten müssen.

Zwei Dinge, die beim Bauen nicht offensichtlich waren:

Viele Geräte führen keinen Regler für Element 0, sondern nur je Kanal.
Fragt man bloß den Hauptregler, hält man ein regelbares Gerät für
unregelbar. Also erst der Hauptregler, dann die Kanäle — und beim Setzen
beide Kanäle, sonst wandert das Stereobild.

Manche Geräte bringen gar keinen Regler mit, HDMI etwa. Dort steht „hier
nicht regelbar" statt eines Reglers, der nichts tut.

Warntöne folgen dem Ausgabegerät mit. Zwei getrennte Wahlen sind die
Quelle für „warum kommt der Systemton aus dem Notebook".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:28:39 +02:00

314 lines
14 KiB
Swift

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<AudioObjectID>.size)
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),
&address, 0, nil, &size, &ids) == noErr else { return [] }
return ids.compactMap { id in
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)
}
}
private static func name(of device: AudioObjectID) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioObjectPropertyName,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size = UInt32(MemoryLayout<CFString?>.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<AudioBufferList>.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<AudioObjectID>.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<AudioObjectID>.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<Float32>.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<Float32>.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<UInt32>.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<UInt32>.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)
}
}