Files
onyx/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift
Scarriffle 312514e6ce Eigene Aggregate-Geräte gehören nicht in die Geräteauswahl
Für jedes geregelte Programm legt Onyx ein Aggregate-Gerät an. Sie sind als
privat markiert — das verbirgt sie aber nur vor **fremden** Prozessen, nicht
vor Onyx selbst. In der Ausgabeauswahl standen sie deshalb mitten zwischen
den echten Geräten, und wer eines davon wählt, hängt seinen Ton in eine
Schleife: die Ausgabe eines Taps wird zur Quelle desselben Taps.

Erkannt werden sie über einen Vorsatz in der Gerätekennung, nicht über den
Namen. Der Name enthält den Programmnamen und ließe sich von einem echten
Gerät nicht sicher unterscheiden — die Kennung vergibt Onyx selbst.

Der Vorsatz nennt Onyx beim Namen: taucht so ein Gerät doch einmal irgendwo
auf, im Audio-MIDI-Setup etwa, sieht man, wer es angelegt hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:24:12 +02:00

340 lines
15 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
// 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<CFString?>.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<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)
}
}