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>
340 lines
15 KiB
Swift
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)
|
|
}
|
|
}
|