diff --git a/Onyx/MixerMenuBarModule.swift b/Onyx/MixerMenuBarModule.swift index 0f15b99..d59bba0 100644 --- a/Onyx/MixerMenuBarModule.swift +++ b/Onyx/MixerMenuBarModule.swift @@ -17,10 +17,14 @@ final class MixerMenuBarModule: MenuBarModule { var shortLabel: String { String(localized: "mixer.menubar.short") } private let mixer: AudioMixer + private let system: SystemAudio private var view: MixerStatusView? private var isActive = false - init(mixer: AudioMixer) { self.mixer = mixer } + init(mixer: AudioMixer, system: SystemAudio) { + self.mixer = mixer + self.system = system + } func makeStatusView(presentation: MenuBarPresentation) -> NSView { let view = MixerStatusView() @@ -32,7 +36,7 @@ final class MixerMenuBarModule: MenuBarModule { func makePopoverView() -> AnyView { AnyView( ScrollView { - MixerView(mixer: mixer) + MixerView(mixer: mixer, system: system) .padding(14) } .scrollIndicators(.automatic) diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index ae11aee..d7191ed 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -47,6 +47,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let settingsWindow = SettingsWindowController() private let fanControl = FanControl() private let audioMixer = AudioMixer() + private let systemAudio = SystemAudio() private let launchAtLogin = LaunchAtLogin() private let onboarding = OnboardingWindowController() @@ -103,7 +104,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Mixer und Lüfter gehören genauso in die Leiste wie die Messwerte: // beides regelt man mitten in etwas anderem, und dafür ist der Weg // über ein Fenster zu weit. - menuBar.register(MixerMenuBarModule(mixer: audioMixer), + menuBar.register(MixerMenuBarModule(mixer: audioMixer, system: systemAudio), settings: model.menuBarSettings(for: "audio")) menuBar.register(FanMenuBarModule(control: fanControl), settings: model.menuBarSettings(for: "fans")) @@ -218,7 +219,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.networkModel = networkModel WidgetRegistry.shared.register(NetworkWidget(model: networkModel)) - WidgetRegistry.shared.register(AudioMixerWidget(mixer: audioMixer)) + WidgetRegistry.shared.register(AudioMixerWidget(mixer: audioMixer, system: systemAudio)) WidgetRegistry.shared.register(FanWidget(control: fanControl)) diff --git a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings index 4c2b49a..a3e43b2 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings @@ -1,143 +1,221 @@ { - "sourceLanguage" : "en", - "strings" : { - "" : { - - }, - "–" : { - "comment" : "A placeholder for the volume of a process.", - "isCommentAutoGenerated" : true - }, - "%lld %%" : { - "comment" : "A row of controls for adjusting the volume and muting a process.", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld %%" + "sourceLanguage": "en", + "strings": { + "": {}, + "%lld %%": { + "comment": "A row of controls for adjusting the volume and muting a process.", + "isCommentAutoGenerated": true, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld %%" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld %%" + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld %%" } } } }, - "mixer.name" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Audio-Mixer" + "mixer.input": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Eingabe" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Audio mixer" + "en": { + "stringUnit": { + "state": "translated", + "value": "Input" } } } }, - "mixer.noProcesses" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kein Programm gibt gerade Ton aus" + "mixer.name": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Audio-Mixer" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No app is playing audio" + "en": { + "stringUnit": { + "state": "translated", + "value": "Audio mixer" } } } }, - "mixer.off" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mixer ausschalten" + "mixer.noProcesses": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Programm gibt gerade Ton aus" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Turn mixer off" + "en": { + "stringUnit": { + "state": "translated", + "value": "No app is playing audio" } } } }, - "mixer.off.explain" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lautstärke einzelner Programme regeln, auch über 100 %." + "mixer.notAdjustable": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hier nicht regelbar" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Set the volume of individual apps, including above 100 %." + "en": { + "stringUnit": { + "state": "translated", + "value": "Not adjustable here" } } } }, - "mixer.off.warning" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx leitet den Ton der geregelten Programme um. Beim Ausschalten ist der ursprüngliche Weg sofort wiederhergestellt." + "mixer.off": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mixer ausschalten" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx routes the audio of the apps you control. Turning the mixer off restores the original path immediately." + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn mixer off" } } } }, - "mixer.on" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mixer einschalten" + "mixer.off.explain": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lautstärke einzelner Programme regeln, auch über 100 %." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Turn mixer on" + "en": { + "stringUnit": { + "state": "translated", + "value": "Set the volume of individual apps, including above 100 %." } } } }, - "mixer.release" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nicht mehr regeln" + "mixer.off.warning": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx leitet den Ton der geregelten Programme um. Beim Ausschalten ist der ursprüngliche Weg sofort wiederhergestellt." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stop controlling" + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx routes the audio of the apps you control. Turning the mixer off restores the original path immediately." } } } + }, + "mixer.on": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mixer einschalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn mixer on" + } + } + } + }, + "mixer.output": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgabe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Output" + } + } + } + }, + "mixer.release": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht mehr regeln" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop controlling" + } + } + } + }, + "mixer.section.apps": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Programme" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Programs" + } + } + } + }, + "mixer.section.system": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "System" + } + } + } + }, + "–": { + "comment": "A placeholder for the volume of a process.", + "isCommentAutoGenerated": true } }, - "version" : "1.1" -} \ No newline at end of file + "version": "1.1" +} diff --git a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift index fca7c5e..2a21b67 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppKit import OnyxDesign import OnyxWidgetKit +import CoreAudio /// Die Mischpult-Ansicht. Dieselbe im Panel und im Menüleisten-Popover. /// @@ -14,16 +15,27 @@ import OnyxWidgetKit /// gewöhnliche Lautstärke nicht. public struct MixerView: View { let mixer: AudioMixer + let system: SystemAudio /// Im Panel ist es eng, im Popover nicht. let compact: Bool - public init(mixer: AudioMixer, compact: Bool = false) { + public init(mixer: AudioMixer, system: SystemAudio, compact: Bool = false) { self.mixer = mixer + self.system = system self.compact = compact } public var body: some View { - VStack(alignment: .leading, spacing: compact ? 10 : 14) { + VStack(alignment: .leading, spacing: compact ? 10 : 12) { + // Die Systemregler stehen **vor** den Programmen und außerhalb des + // Mixer-Schalters: sie brauchen keinen Tap, keine Berechtigung und + // keinen laufenden Mixer. Wer nur die Lautstärke sucht, soll dafür + // nichts einschalten müssen. + SectionTitle("mixer.section.system", compact: compact) + SystemSection(system: system, compact: compact) + + SectionTitle("mixer.section.apps", compact: compact) + if mixer.isEnabled { if mixer.processes.isEmpty { Text("mixer.noProcesses", bundle: .module) @@ -314,15 +326,216 @@ public struct AudioMixerWidget: OnyxWidget { public let symbolName = "slider.horizontal.3" private let mixer: AudioMixer + private let system: SystemAudio - public init(mixer: AudioMixer) { self.mixer = mixer } + public init(mixer: AudioMixer, system: SystemAudio) { + self.mixer = mixer + self.system = system + } public func makeView() -> AnyView { AnyView( ScrollView { - MixerView(mixer: mixer, compact: true) + MixerView(mixer: mixer, system: system, compact: true) } .scrollIndicators(.never) ) } } + +// MARK: - Systemzeilen + +private struct SectionTitle: View { + let key: LocalizedStringKey + let compact: Bool + + init(_ key: LocalizedStringKey, compact: Bool) { + self.key = key + self.compact = compact + } + + var body: some View { + Text(key, bundle: .module) + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textTertiary) + .textCase(.uppercase) + .padding(.top, compact ? 0 : 2) + } +} + +/// Ausgabe, Eingabe und Gerätewahl. +/// +/// Bewusst dieselbe Zeilenform wie bei den Programmen: Symbol, Stummschalter, +/// Regler, Wert. Zwei verschiedene Regler in einem Fenster wären eine +/// Behauptung, dass es zwei verschiedene Dinge sind — es ist aber beides +/// Lautstärke. +private struct SystemSection: View { + let system: SystemAudio + let compact: Bool + + var body: some View { + VStack(spacing: 0) { + SystemRow( + symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill", + title: outputName, + devices: system.outputDevices, + selected: system.outputDeviceID, + volume: system.outputVolume, + muted: system.outputMuted, + adjustable: system.outputIsAdjustable, + compact: compact, + onVolume: { system.setOutputVolume($0) }, + onMute: { system.setOutputMuted(!system.outputMuted) }, + onSelect: { system.selectOutput($0) }) + + Divider().overlay(Onyx.Color.hairline) + + SystemRow( + symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill", + title: inputName, + devices: system.inputDevices, + selected: system.inputDeviceID, + volume: system.inputVolume, + muted: system.inputMuted, + adjustable: system.inputIsAdjustable, + compact: compact, + onVolume: { system.setInputVolume($0) }, + onMute: { system.setInputMuted(!system.inputMuted) }, + onSelect: { system.selectInput($0) }) + } + .background(Onyx.Color.elevated.opacity(0.55), + in: .rect(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Onyx.Color.hairline, lineWidth: 1) + } + } + + private var outputName: String { + system.outputDevices.first { $0.id == system.outputDeviceID }?.name + ?? String(localized: "mixer.output", bundle: .module) + } + + private var inputName: String { + system.inputDevices.first { $0.id == system.inputDeviceID }?.name + ?? String(localized: "mixer.input", bundle: .module) + } +} + +private struct SystemRow: View { + let symbol: String + let title: String + let devices: [AudioDevice] + let selected: AudioObjectID + let volume: Double + let muted: Bool + let adjustable: Bool + let compact: Bool + let onVolume: (Double) -> Void + let onMute: () -> Void + let onSelect: (AudioDevice) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: compact ? 5 : 6) { + // Der Gerätename **ist** die Auswahl: ein eigenes Auswahlfeld + // daneben wäre eine zweite Stelle für dieselbe Sache. + Menu { + ForEach(devices) { device in + Button { onSelect(device) } label: { + if device.id == selected { + Label(device.name, systemImage: "checkmark") + } else { + Text(device.name) + } + } + } + } label: { + HStack(spacing: 4) { + Text(title) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 7)) + .foregroundStyle(Onyx.Color.textTertiary) + Spacer(minLength: 0) + } + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + + HStack(spacing: 9) { + Button(action: onMute) { + Image(systemName: symbol) + .font(.system(size: 11)) + .frame(width: 15, alignment: .leading) + } + .buttonStyle(.plain) + .foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary) + + if adjustable { + SystemVolumeBar(volume: volume, isActive: !muted, onChange: onVolume) + Text("\(Int(volume * 100)) %") + .font(Onyx.Font.metricSmall).monospacedDigit() + .foregroundStyle(Onyx.Color.textTertiary) + .frame(width: 36, alignment: .trailing) + } else { + // Manche Geräte bringen keinen eigenen Regler mit — HDMI, + // viele USB-Interfaces. Ein Regler, der nichts tut, wäre + // eine Pseudobedienung. + Text("mixer.notAdjustable", bundle: .module) + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textTertiary) + Spacer(minLength: 0) + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, compact ? 7 : 9) + } +} + +/// Wie der Programmregler, nur ohne Verstärkung und ohne Pegel: die +/// Systemlautstärke geht von null bis hundert, mehr gibt Core Audio nicht her. +private struct SystemVolumeBar: View { + let volume: Double + let isActive: Bool + let onChange: (Double) -> Void + + @State private var isDragging = false + + var body: some View { + GeometryReader { geometry in + let knob: CGFloat = isDragging ? 13 : 11 + let travel = max(geometry.size.width - knob, 1) + let fraction = min(max(volume, 0), 1) + + ZStack(alignment: .leading) { + Capsule().fill(Onyx.Color.surface).frame(height: 6) + Capsule() + .fill(Onyx.Color.accent) + .frame(width: travel * fraction + knob / 2, height: 6) + .opacity(isActive ? 1 : 0.3) + Circle() + .fill(.white) + .frame(width: knob, height: knob) + .shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5) + .offset(x: travel * fraction) + .opacity(isActive ? 1 : 0.55) + } + .frame(height: 16) + .contentShape(.rect) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + isDragging = true + onChange(min(max((value.location.x - knob / 2) / travel, 0), 1)) + } + .onEnded { _ in isDragging = false }) + } + .frame(height: 16) + .animation(Onyx.Motion.value, value: isDragging) + } +} diff --git a/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift b/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift new file mode 100644 index 0000000..f9d3d17 --- /dev/null +++ b/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift @@ -0,0 +1,313 @@ +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.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.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.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.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.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.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.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.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.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) + } +}