Files
onyx/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift
T
ScarriffleandClaude Opus 5 edb327cabe Kacheln und Fenster sprechen dieselbe Sprache
Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund
hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt
Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl.

Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren
deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht
mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz
auf schwarz endet.

Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das
lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient,
bis jede Kachel einmal zu sehen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:05:51 +02:00

410 lines
19 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] = []
/// Zuhörer, die am gewählten Gerät hängen und beim Wechsel neu gesetzt
/// werden müssen.
private var deviceListeners: [PropertyListener] = []
/// Die Pegel von Ausgabe und Eingabe.
///
/// Hier und nicht im Mixer: sie gehören zu den Systemreglern, und die
/// stehen bewusst außerhalb des Mixer-Schalters.
public let levels = SystemLevels()
public init() {
refresh()
observeSystemChanges()
}
// MARK: - Lesen
public func refresh() {
let previousOutput = outputDeviceID
let previousInput = inputDeviceID
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)
// Nur bei einem echten Wechsel neu anmelden. Ein Abmelden mitten im
// Rückruf, der gerade läuft, ist eine Einladung zum Absturz.
if outputDeviceID != previousOutput || inputDeviceID != previousInput
|| deviceListeners.isEmpty {
observeCurrentDevices()
}
}
// 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() }
}
}
}
/// Zuhörer an Lautstärke und Stummschaltung der **gerade gewählten** Geräte.
///
/// **Das fehlte.** Beobachtet wurden nur Gerätewechsel; wer die Lautstärke
/// per Tastatur änderte, bekam davon nichts mit — der Regler im Mixer stand
/// dann auf einem Wert, den es nicht mehr gab, bis irgendetwas anderes ein
/// `refresh()` auslöste.
///
/// Die Zuhörer hängen am Gerät, nicht am Systemobjekt, und müssen deshalb
/// bei jedem Gerätewechsel neu gesetzt werden.
private func observeCurrentDevices() {
deviceListeners = []
for (device, scope) in [(outputDeviceID, kAudioDevicePropertyScopeOutput),
(inputDeviceID, kAudioDevicePropertyScopeInput)] {
guard device != AudioObjectID(kAudioObjectUnknown) else { continue }
for selector in [kAudioDevicePropertyVolumeScalar, kAudioDevicePropertyMute] {
// Manche Geräte führen keine Hauptlautstärke, sondern nur
// Kanäle. Dort scheitert das Anmelden — das ist kein Fehler,
// dort gibt es schlicht nichts zu hören.
if let listener = PropertyListener(object: device,
selector: selector,
scope: scope,
handler: { [weak self] in
Task { @MainActor in self?.readLevels() }
}) {
deviceListeners.append(listener)
}
}
}
}
/// Nur die Pegel und Schalter neu lesen — ohne Geräteliste.
///
/// Getrennt von `refresh()`, weil dieser Weg aus einem Rückruf kommt, der
/// **während** einer Änderung feuert: dort auch noch die Geräte neu
/// aufzuzählen wäre Arbeit für nichts.
private func readLevels() {
outputVolume = Self.volume(outputDeviceID, scope: kAudioDevicePropertyScopeOutput) ?? 0
outputMuted = Self.muted(outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
inputVolume = Self.volume(inputDeviceID, scope: kAudioDevicePropertyScopeInput) ?? 0
inputMuted = Self.muted(inputDeviceID, scope: kAudioDevicePropertyScopeInput)
}
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
private var address: AudioObjectPropertyAddress
private let block: AudioObjectPropertyListenerBlock
/// - Parameters:
/// - object: Standardmäßig das Systemobjekt. Für Lautstärke und
/// Stummschaltung muss es das **Gerät** sein — die sind keine
/// Eigenschaften der Hardware im Allgemeinen, sondern dieser Kiste.
/// - scope: Ein- oder Ausgabe. Global genügt nur bei Geräteeigenschaften.
init?(object: AudioObjectID = AudioObjectID(kAudioObjectSystemObject),
selector: AudioObjectPropertySelector,
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal,
handler: @escaping @Sendable () -> Void) {
self.object = object
address = AudioObjectPropertyAddress(
mSelector: selector,
mScope: scope,
mElement: kAudioObjectPropertyElementMain)
block = { _, _ in handler() }
guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr
else { return nil }
}
deinit {
AudioObjectRemovePropertyListenerBlock(object, &address, .main, block)
}
}