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>
410 lines
19 KiB
Swift
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)
|
|
}
|
|
}
|