Systemlautstärke und Gerätewahl im Mixer

Ausgabe und Eingabe stehen jetzt über den Programmen — mit Lautstärke,
Stummschalter und der Gerätewahl direkt am Namen. Ein eigenes Auswahlfeld
daneben wäre eine zweite Stelle für dieselbe Sache.

Sie stehen **außerhalb** des Mixer-Schalters, und das ist der Punkt: hier
wird kein Ton abgegriffen, sondern es werden die Regler des Systems bedient.
Das braucht keinen Tap, kein Aggregate-Gerät und keine Berechtigung — wer
nur die Lautstärke sucht, soll dafür nichts einschalten müssen.

Zwei Dinge, die beim Bauen nicht offensichtlich waren:

Viele Geräte führen keinen Regler für Element 0, sondern nur je Kanal.
Fragt man bloß den Hauptregler, hält man ein regelbares Gerät für
unregelbar. Also erst der Hauptregler, dann die Kanäle — und beim Setzen
beide Kanäle, sonst wandert das Stereobild.

Manche Geräte bringen gar keinen Regler mit, HDMI etwa. Dort steht „hier
nicht regelbar" statt eines Reglers, der nichts tut.

Warntöne folgen dem Ausgabegerät mit. Zwei getrennte Wahlen sind die
Quelle für „warum kommt der Systemton aus dem Notebook".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-11 19:28:39 +02:00
parent 68e53d3f6f
commit bf799d57d9
5 changed files with 710 additions and 101 deletions

View File

@@ -17,10 +17,14 @@ final class MixerMenuBarModule: MenuBarModule {
var shortLabel: String { String(localized: "mixer.menubar.short") } var shortLabel: String { String(localized: "mixer.menubar.short") }
private let mixer: AudioMixer private let mixer: AudioMixer
private let system: SystemAudio
private var view: MixerStatusView? private var view: MixerStatusView?
private var isActive = false 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 { func makeStatusView(presentation: MenuBarPresentation) -> NSView {
let view = MixerStatusView() let view = MixerStatusView()
@@ -32,7 +36,7 @@ final class MixerMenuBarModule: MenuBarModule {
func makePopoverView() -> AnyView { func makePopoverView() -> AnyView {
AnyView( AnyView(
ScrollView { ScrollView {
MixerView(mixer: mixer) MixerView(mixer: mixer, system: system)
.padding(14) .padding(14)
} }
.scrollIndicators(.automatic) .scrollIndicators(.automatic)

View File

@@ -47,6 +47,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private let settingsWindow = SettingsWindowController() private let settingsWindow = SettingsWindowController()
private let fanControl = FanControl() private let fanControl = FanControl()
private let audioMixer = AudioMixer() private let audioMixer = AudioMixer()
private let systemAudio = SystemAudio()
private let launchAtLogin = LaunchAtLogin() private let launchAtLogin = LaunchAtLogin()
private let onboarding = OnboardingWindowController() 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: // 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 // beides regelt man mitten in etwas anderem, und dafür ist der Weg
// über ein Fenster zu weit. // über ein Fenster zu weit.
menuBar.register(MixerMenuBarModule(mixer: audioMixer), menuBar.register(MixerMenuBarModule(mixer: audioMixer, system: systemAudio),
settings: model.menuBarSettings(for: "audio")) settings: model.menuBarSettings(for: "audio"))
menuBar.register(FanMenuBarModule(control: fanControl), menuBar.register(FanMenuBarModule(control: fanControl),
settings: model.menuBarSettings(for: "fans")) settings: model.menuBarSettings(for: "fans"))
@@ -218,7 +219,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self.networkModel = networkModel self.networkModel = networkModel
WidgetRegistry.shared.register(NetworkWidget(model: 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)) WidgetRegistry.shared.register(FanWidget(control: fanControl))

View File

@@ -1,143 +1,221 @@
{ {
"sourceLanguage" : "en", "sourceLanguage": "en",
"strings" : { "strings": {
"" : { "": {},
"%lld %%": {
}, "comment": "A row of controls for adjusting the volume and muting a process.",
"" : { "isCommentAutoGenerated": true,
"comment" : "A placeholder for the volume of a process.", "localizations": {
"isCommentAutoGenerated" : true "de": {
}, "stringUnit": {
"%lld %%" : { "state": "translated",
"comment" : "A row of controls for adjusting the volume and muting a process.", "value": "%lld %%"
"isCommentAutoGenerated" : true,
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld %%"
} }
}, },
"en" : { "en": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "%lld %%" "value": "%lld %%"
} }
} }
} }
}, },
"mixer.name" : { "mixer.input": {
"localizations" : { "localizations": {
"de" : { "de": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Audio-Mixer" "value": "Eingabe"
} }
}, },
"en" : { "en": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Audio mixer" "value": "Input"
} }
} }
} }
}, },
"mixer.noProcesses" : { "mixer.name": {
"localizations" : { "localizations": {
"de" : { "de": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Kein Programm gibt gerade Ton aus" "value": "Audio-Mixer"
} }
}, },
"en" : { "en": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "No app is playing audio" "value": "Audio mixer"
} }
} }
} }
}, },
"mixer.off" : { "mixer.noProcesses": {
"localizations" : { "localizations": {
"de" : { "de": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Mixer ausschalten" "value": "Kein Programm gibt gerade Ton aus"
} }
}, },
"en" : { "en": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Turn mixer off" "value": "No app is playing audio"
} }
} }
} }
}, },
"mixer.off.explain" : { "mixer.notAdjustable": {
"localizations" : { "localizations": {
"de" : { "de": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Lautstärke einzelner Programme regeln, auch über 100 %." "value": "Hier nicht regelbar"
} }
}, },
"en" : { "en": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Set the volume of individual apps, including above 100 %." "value": "Not adjustable here"
} }
} }
} }
}, },
"mixer.off.warning" : { "mixer.off": {
"localizations" : { "localizations": {
"de" : { "de": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Onyx leitet den Ton der geregelten Programme um. Beim Ausschalten ist der ursprüngliche Weg sofort wiederhergestellt." "value": "Mixer ausschalten"
} }
}, },
"en" : { "en": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Onyx routes the audio of the apps you control. Turning the mixer off restores the original path immediately." "value": "Turn mixer off"
} }
} }
} }
}, },
"mixer.on" : { "mixer.off.explain": {
"localizations" : { "localizations": {
"de" : { "de": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Mixer einschalten" "value": "Lautstärke einzelner Programme regeln, auch über 100 %."
} }
}, },
"en" : { "en": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Turn mixer on" "value": "Set the volume of individual apps, including above 100 %."
} }
} }
} }
}, },
"mixer.release" : { "mixer.off.warning": {
"localizations" : { "localizations": {
"de" : { "de": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Nicht mehr regeln" "value": "Onyx leitet den Ton der geregelten Programme um. Beim Ausschalten ist der ursprüngliche Weg sofort wiederhergestellt."
} }
}, },
"en" : { "en": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Stop controlling" "value": "Onyx routes the audio of the apps you control. Turning the mixer off restores the original path immediately."
}
} }
} }
} }
}, },
"version" : "1.1" "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"
} }

View File

@@ -2,6 +2,7 @@ import SwiftUI
import AppKit import AppKit
import OnyxDesign import OnyxDesign
import OnyxWidgetKit import OnyxWidgetKit
import CoreAudio
/// Die Mischpult-Ansicht. Dieselbe im Panel und im Menüleisten-Popover. /// Die Mischpult-Ansicht. Dieselbe im Panel und im Menüleisten-Popover.
/// ///
@@ -14,16 +15,27 @@ import OnyxWidgetKit
/// gewöhnliche Lautstärke nicht. /// gewöhnliche Lautstärke nicht.
public struct MixerView: View { public struct MixerView: View {
let mixer: AudioMixer let mixer: AudioMixer
let system: SystemAudio
/// Im Panel ist es eng, im Popover nicht. /// Im Panel ist es eng, im Popover nicht.
let compact: Bool let compact: Bool
public init(mixer: AudioMixer, compact: Bool = false) { public init(mixer: AudioMixer, system: SystemAudio, compact: Bool = false) {
self.mixer = mixer self.mixer = mixer
self.system = system
self.compact = compact self.compact = compact
} }
public var body: some View { 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.isEnabled {
if mixer.processes.isEmpty { if mixer.processes.isEmpty {
Text("mixer.noProcesses", bundle: .module) Text("mixer.noProcesses", bundle: .module)
@@ -314,15 +326,216 @@ public struct AudioMixerWidget: OnyxWidget {
public let symbolName = "slider.horizontal.3" public let symbolName = "slider.horizontal.3"
private let mixer: AudioMixer 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 { public func makeView() -> AnyView {
AnyView( AnyView(
ScrollView { ScrollView {
MixerView(mixer: mixer, compact: true) MixerView(mixer: mixer, system: system, compact: true)
} }
.scrollIndicators(.never) .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)
}
}

View File

@@ -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<AudioObjectID>.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<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)
}
}