diff --git a/Onyx/Info.plist b/Onyx/Info.plist
index 698d4bf..0ed7710 100644
--- a/Onyx/Info.plist
+++ b/Onyx/Info.plist
@@ -40,6 +40,8 @@
Onyx zeigt das Wetter an deinem aktuellen Standort. Du kannst stattdessen auch einen festen Ort wählen.
NSAppleEventsUsageDescription
Onyx steuert Spotify und Musik, wenn die universelle Medienschnittstelle nicht verfügbar ist.
+ NSMicrophoneUsageDescription
+ Onyx zeigt im Mixer an, wie stark dein Mikrofon gerade ausschlägt. Gemessen wird nur, solange die Ansicht offen ist — aufgezeichnet wird nichts.
NSAudioCaptureUsageDescription
Onyx braucht Zugriff auf die Audioausgabe, um die Lautstärke einzelner Programme zu regeln. Es wird nichts aufgezeichnet.
diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift
index b5085dc..52d3a22 100644
--- a/Onyx/OnyxApp.swift
+++ b/Onyx/OnyxApp.swift
@@ -105,6 +105,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
menuBar.register(NetworkMenuBarModule(model: networkModel),
settings: model.menuBarSettings(for: "network"))
}
+ // Der Ausgangspegel muss die Programme auslassen, die der Mixer bereits
+ // abgreift — nur der Mixer weiß, welche das sind.
+ systemAudio.levels.mutedProcesses = { [weak audioMixer] in
+ audioMixer?.tappedProcessIDs ?? []
+ }
+
// 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.
diff --git a/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift b/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift
index 7da8b55..abc2f86 100644
--- a/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift
+++ b/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift
@@ -245,6 +245,15 @@ public final class AudioMixer {
public func isManaged(_ bundleID: String) -> Bool { managed.contains(bundleID) }
+ /// Die Prozesse, deren Originalton gerade stummgeschaltet ist.
+ ///
+ /// Der Ausgangspegel muss sie auslassen: ein Tap sieht das Signal **vor**
+ /// dem Stummschalten, sie zählten sonst doppelt — einmal als Original, das
+ /// niemand hört, und einmal als das, was Onyx daraus macht.
+ public var tappedProcessIDs: [AudioObjectID] {
+ taps.values.map(\.process.objectID)
+ }
+
/// Der Ausschlag eines Programms, 0…1 — für die Pegelanzeige.
public func level(for bundleID: String) -> Double { levels[bundleID] ?? 0 }
diff --git a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift
index 96437fc..4eabde4 100644
--- a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift
+++ b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift
@@ -86,6 +86,11 @@ public struct MixerView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.animation(Onyx.Motion.value, value: mixer.groups.count)
+ // Die Pegel laufen nur, solange die Ansicht sichtbar ist. Der
+ // Mikrofonpegel schaltet dabei den orangen Punkt an — ein Messgerät,
+ // das im Hintergrund weiterläuft, wäre hier das Falsche.
+ .onAppear { system.levels.addConsumer() }
+ .onDisappear { system.levels.removeConsumer() }
}
}
@@ -463,6 +468,7 @@ private struct SystemSection: View {
devices: system.outputDevices,
selected: system.outputDeviceID,
volume: system.outputVolume,
+ level: system.levels.output,
muted: system.outputMuted,
adjustable: system.outputIsAdjustable,
compact: compact,
@@ -478,6 +484,7 @@ private struct SystemSection: View {
devices: system.inputDevices,
selected: system.inputDeviceID,
volume: system.inputVolume,
+ level: system.levels.input,
muted: system.inputMuted,
adjustable: system.inputIsAdjustable,
compact: compact,
@@ -510,6 +517,7 @@ private struct SystemRow: View {
let devices: [AudioDevice]
let selected: AudioObjectID
let volume: Double
+ let level: Double
let muted: Bool
let adjustable: Bool
let compact: Bool
@@ -558,7 +566,8 @@ private struct SystemRow: View {
.foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary)
if adjustable {
- SystemVolumeBar(volume: volume, isActive: !muted, onChange: onVolume)
+ SystemVolumeBar(volume: volume, level: level,
+ isActive: !muted, onChange: onVolume)
Text("\(Int(volume * 100)) %")
.font(Onyx.Font.metricSmall).monospacedDigit()
.foregroundStyle(Onyx.Color.textTertiary)
@@ -583,6 +592,7 @@ private struct SystemRow: View {
/// Systemlautstärke geht von null bis hundert, mehr gibt Core Audio nicht her.
private struct SystemVolumeBar: View {
let volume: Double
+ let level: Double
let isActive: Bool
let onChange: (Double) -> Void
@@ -596,10 +606,24 @@ private struct SystemVolumeBar: View {
ZStack(alignment: .leading) {
Capsule().fill(Onyx.Color.surface).frame(height: 6)
+ let fillWidth = travel * fraction + knob / 2
+
Capsule()
.fill(Onyx.Color.accent)
- .frame(width: travel * fraction + knob / 2, height: 6)
+ .frame(width: fillWidth, height: 6)
.opacity(isActive ? 1 : 0.3)
+
+ // Derselbe mitlaufende Pegel wie in den Programmzeilen. Er
+ // sitzt **im** Regler, weil eine zweite Leiste daneben doppelt
+ // so viel Grafik für dieselbe Auskunft wäre.
+ if isActive, level > 0.001 {
+ Capsule()
+ .fill(.white.opacity(0.4))
+ .frame(width: fillWidth * meterFraction, height: 6)
+ .blendMode(.plusLighter)
+ .allowsHitTesting(false)
+ }
+
Circle()
.fill(.white)
.frame(width: knob, height: knob)
@@ -620,4 +644,11 @@ private struct SystemVolumeBar: View {
.frame(height: 16)
.animation(Onyx.Motion.value, value: isDragging)
}
+
+ /// Ausschläge sind linear in der Amplitude und damit optisch nutzlos:
+ /// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
+ /// Bereich so weit, dass man überhaupt etwas sieht.
+ private var meterFraction: Double {
+ min(1, sqrt(min(max(level, 0), 1)))
+ }
}
diff --git a/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift b/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift
index 48d7885..524555c 100644
--- a/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift
+++ b/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift
@@ -45,6 +45,12 @@ public final class SystemAudio {
private var listeners: [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()
diff --git a/Packages/OnyxKit/Sources/AudioProvider/SystemLevels.swift b/Packages/OnyxKit/Sources/AudioProvider/SystemLevels.swift
new file mode 100644
index 0000000..850d06c
--- /dev/null
+++ b/Packages/OnyxKit/Sources/AudioProvider/SystemLevels.swift
@@ -0,0 +1,329 @@
+import Foundation
+import Synchronization
+import AudioToolbox
+import CoreAudio
+import AVFoundation
+import OSLog
+
+private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio")
+
+/// Der höchste Ausschlag seit dem letzten Abholen — als Bitmuster, weil
+/// `Atomic` keine Fließkommazahlen führt.
+///
+/// Sperrenfrei, weil geschrieben wird, während der Audiothread läuft. Eine
+/// gewöhnliche Eigenschaft wäre dort ein Wettlauf, eine Sperre ein Aussetzer.
+final class PeakMeter: Sendable {
+ private let bits = Atomic(0)
+
+ func record(_ magnitude: Float) {
+ let previous = Float(bitPattern: bits.load(ordering: .relaxed))
+ if magnitude > previous { bits.store(magnitude.bitPattern, ordering: .relaxed) }
+ }
+
+ /// Holt den Höchstwert ab und setzt zurück, damit die Anzeige abfällt,
+ /// statt auf der Spitze stehen zu bleiben.
+ func take() -> Float { Float(bitPattern: bits.exchange(0, ordering: .relaxed)) }
+}
+
+/// Misst, was gerade aus dem Lautsprecher kommt.
+///
+/// Über einen globalen Tap, also denselben Weg wie der Mixer — nur ohne
+/// Stummschalten und ohne Ausgabe. Ausgenommen werden die Programme, die der
+/// Mixer bereits abgreift: deren Originalton ist stummgeschaltet, aber ein Tap
+/// sieht ihn trotzdem. Sie zählten sonst doppelt — einmal als Original, das
+/// niemand hört, und einmal als das, was Onyx daraus macht.
+final class OutputMeter {
+
+ let peak = PeakMeter()
+ private(set) var excluded: [AudioObjectID] = []
+ private var tapID = AudioObjectID(kAudioObjectUnknown)
+ private var aggregateID = AudioObjectID(kAudioObjectUnknown)
+ private var procID: AudioDeviceIOProcID?
+
+ var isRunning: Bool { procID != nil }
+
+ deinit { stop() }
+
+ func start(excluding processes: [AudioObjectID], outputDeviceUID: String) {
+ stop()
+ excluded = processes
+
+ let tapUUID = UUID()
+ let description = CATapDescription(stereoGlobalTapButExcludeProcesses: processes)
+ description.uuid = tapUUID
+ description.name = "Onyx – Ausgangspegel"
+ description.isPrivate = true
+ // Nur zuhören. Ein Pegelmesser, der die Wiedergabe verändert, wäre
+ // kein Messgerät.
+ description.muteBehavior = .unmuted
+
+ guard AudioHardwareCreateProcessTap(description, &tapID) == noErr else {
+ tapID = AudioObjectID(kAudioObjectUnknown)
+ return
+ }
+
+ let aggregate: [String: Any] = [
+ kAudioAggregateDeviceNameKey: "Onyx Pegel",
+ kAudioAggregateDeviceUIDKey: ProcessTap.aggregateUIDPrefix + UUID().uuidString,
+ kAudioAggregateDeviceIsPrivateKey: true,
+ kAudioAggregateDeviceIsStackedKey: false,
+ kAudioAggregateDeviceMainSubDeviceKey: outputDeviceUID,
+ kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputDeviceUID]],
+ kAudioAggregateDeviceTapListKey: [[
+ kAudioSubTapUIDKey: tapUUID.uuidString,
+ kAudioSubTapDriftCompensationKey: true,
+ ]],
+ ]
+ guard AudioHardwareCreateAggregateDevice(aggregate as CFDictionary, &aggregateID) == noErr
+ else {
+ aggregateID = AudioObjectID(kAudioObjectUnknown)
+ stop()
+ return
+ }
+
+ var proc: AudioDeviceIOProcID?
+ let status = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) {
+ [peak] _, inputData, _, outputData, _ in
+ let input = UnsafeMutableAudioBufferListPointer(
+ UnsafeMutablePointer(mutating: inputData))
+ for buffer in input {
+ guard let data = buffer.mData else { continue }
+ let count = Int(buffer.mDataByteSize) / MemoryLayout.size
+ let samples = data.assumingMemoryBound(to: Float.self)
+ var high: Float = 0
+ for index in 0.. high { high = magnitude }
+ }
+ peak.record(high)
+ }
+ // Die eigene Ausgabe bleibt still: das Gerät gibt auf denselben
+ // Lautsprecher aus, und ungeschriebene Puffer enthalten, was zuletzt
+ // darin stand.
+ AudioBuffers.silence(outputData)
+ }
+ guard status == noErr, let proc else { stop(); return }
+ procID = proc
+
+ guard AudioDeviceStart(aggregateID, proc) == noErr else { stop(); return }
+ }
+
+ func stop() {
+ if let procID {
+ AudioDeviceStop(aggregateID, procID)
+ AudioDeviceDestroyIOProcID(aggregateID, procID)
+ self.procID = nil
+ }
+ if aggregateID != kAudioObjectUnknown {
+ AudioHardwareDestroyAggregateDevice(aggregateID)
+ aggregateID = AudioObjectID(kAudioObjectUnknown)
+ }
+ if tapID != kAudioObjectUnknown {
+ AudioHardwareDestroyProcessTap(tapID)
+ tapID = AudioObjectID(kAudioObjectUnknown)
+ }
+ }
+}
+
+/// Misst, was das Mikrofon hört.
+///
+/// **Dafür wird tatsächlich aufgenommen** — anders geht es nicht. Es gibt keine
+/// Eigenschaft am Gerät, die den Ausschlag verrät; einen Pegel bekommt nur, wer
+/// das Signal liest. Genau das meldet macOS mit dem orangen Punkt, und das ist
+/// richtig so. Deshalb läuft diese Messung nur, solange jemand hinschaut, und
+/// hört mit dem Schließen des Popovers sofort wieder auf.
+final class InputMeter {
+
+ let peak = PeakMeter()
+ private var deviceID = AudioObjectID(kAudioObjectUnknown)
+ private var procID: AudioDeviceIOProcID?
+
+ var isRunning: Bool { procID != nil }
+
+ deinit { stop() }
+
+ func start(device: AudioObjectID) {
+ stop()
+ guard device != kAudioObjectUnknown, Self.deliversFloat(device) else { return }
+ deviceID = device
+
+ var proc: AudioDeviceIOProcID?
+ let status = AudioDeviceCreateIOProcIDWithBlock(&proc, device, nil) {
+ [peak] _, inputData, _, outputData, _ in
+ let input = UnsafeMutableAudioBufferListPointer(
+ UnsafeMutablePointer(mutating: inputData))
+ for buffer in input {
+ guard let data = buffer.mData else { continue }
+ let count = Int(buffer.mDataByteSize) / MemoryLayout.size
+ let samples = data.assumingMemoryBound(to: Float.self)
+ var high: Float = 0
+ for index in 0.. high { high = magnitude }
+ }
+ peak.record(high)
+ }
+ // Manche Geräte haben Ein- und Ausgang. Der eigene Ausgabepuffer
+ // bleibt still — er trägt zum Gerät nur bei, was hier hineingeht.
+ AudioBuffers.silence(outputData)
+ }
+ guard status == noErr, let proc else { stop(); return }
+ procID = proc
+
+ if AudioDeviceStart(device, proc) != noErr {
+ log.error("Mikrofonpegel: Gerät startet nicht")
+ stop()
+ }
+ }
+
+ func stop() {
+ if let procID {
+ AudioDeviceStop(deviceID, procID)
+ AudioDeviceDestroyIOProcID(deviceID, procID)
+ self.procID = nil
+ }
+ deviceID = AudioObjectID(kAudioObjectUnknown)
+ }
+
+ /// Liefert das Gerät Fließkommawerte?
+ ///
+ /// Ohne diese Prüfung würden Ganzzahlwerte als Fließkomma gelesen — das
+ /// ergibt keinen zu hohen oder zu niedrigen Pegel, sondern Unsinn.
+ private static func deliversFloat(_ device: AudioObjectID) -> Bool {
+ var address = AudioObjectPropertyAddress(
+ mSelector: kAudioStreamPropertyVirtualFormat,
+ mScope: kAudioObjectPropertyScopeInput,
+ mElement: kAudioObjectPropertyElementMain)
+ var format = AudioStreamBasicDescription()
+ var size = UInt32(MemoryLayout.size)
+ guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &format) == noErr
+ else { return false }
+ return format.mFormatFlags & kAudioFormatFlagIsFloat != 0
+ }
+}
+
+enum AudioBuffers {
+ /// Füllt alle Puffer mit Nullen.
+ static func silence(_ list: UnsafeMutablePointer) {
+ for buffer in UnsafeMutableAudioBufferListPointer(list) {
+ guard let data = buffer.mData else { continue }
+ memset(data, 0, Int(buffer.mDataByteSize))
+ }
+ }
+}
+
+/// Die Pegel von Ausgabe und Eingabe.
+///
+/// Sie laufen nur, solange jemand hinschaut: beide brauchen einen offenen
+/// Audioweg, der Mikrofonpegel obendrein den orangen Punkt. Ein Messgerät, das
+/// im Hintergrund weiterläuft, wäre bei beidem falsch.
+@MainActor
+@Observable
+public final class SystemLevels {
+
+ public private(set) var output: Double = 0
+ public private(set) var input: Double = 0
+
+ /// Ob gerade tatsächlich das Mikrofon gelesen wird — für den Hinweis in der
+ /// Oberfläche. Wer den orangen Punkt sieht, soll in Onyx nachlesen können,
+ /// warum er da ist.
+ public private(set) var isListeningToInput = false
+
+ /// Prozesse, deren Originalton der Mixer bereits stummgeschaltet hat.
+ ///
+ /// Wird von außen gesetzt, weil nur der Mixer sie kennt. Ohne die Angabe
+ /// zählte jedes geregelte Programm doppelt.
+ public var mutedProcesses: @MainActor () -> [AudioObjectID] = { [] }
+
+ @ObservationIgnored private let outputMeter = OutputMeter()
+ @ObservationIgnored private let inputMeter = InputMeter()
+ @ObservationIgnored private var consumers = 0
+ @ObservationIgnored private var timer: Timer?
+
+ public init() {}
+
+ /// Ein Zähler statt eines Schalters: Popover und Panel können gleichzeitig
+ /// offen sein, und das zweite Schließen darf nicht abschalten, was das erste
+ /// noch braucht.
+ public func addConsumer() {
+ consumers += 1
+ guard consumers == 1 else { return }
+ rebuild()
+ let timer = Timer(timeInterval: 1.0 / 15, repeats: true) { [weak self] _ in
+ Task { @MainActor in self?.sample() }
+ }
+ RunLoop.main.add(timer, forMode: .common)
+ self.timer = timer
+ }
+
+ public func removeConsumer() {
+ consumers = max(0, consumers - 1)
+ guard consumers == 0 else { return }
+ timer?.invalidate()
+ timer = nil
+ outputMeter.stop()
+ inputMeter.stop()
+ isListeningToInput = false
+ output = 0
+ input = 0
+ }
+
+ private func rebuild() {
+ if let uid = Self.defaultDeviceUID(kAudioHardwarePropertyDefaultOutputDevice) {
+ outputMeter.start(excluding: mutedProcesses(), outputDeviceUID: uid)
+ }
+ inputMeter.start(device: Self.defaultDevice(kAudioHardwarePropertyDefaultInputDevice))
+ isListeningToInput = inputMeter.isRunning
+ }
+
+ private func sample() {
+ // Ändert sich, welche Programme der Mixer abgreift, muss der Tap neu
+ // aufgebaut werden — sonst zählte ein gerade erst geregeltes Programm
+ // ab dem nächsten Puffer doppelt.
+ let muted = mutedProcesses()
+ if outputMeter.isRunning, muted.sorted() != outputMeter.excluded.sorted() {
+ if let uid = Self.defaultDeviceUID(kAudioHardwarePropertyDefaultOutputDevice) {
+ outputMeter.start(excluding: muted, outputDeviceUID: uid)
+ }
+ }
+
+ // Schnell hoch, langsam runter — wie bei den Programmzeilen, damit
+ // beide Balken dieselbe Sprache sprechen.
+ output = decay(output, toward: Double(outputMeter.peak.take()))
+ input = decay(input, toward: Double(inputMeter.peak.take()))
+ }
+
+ private func decay(_ current: Double, toward peak: Double) -> Double {
+ peak > current ? peak : current * 0.72
+ }
+
+ // MARK: - Core Audio
+
+ 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)
+ guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),
+ &address, 0, nil, &size, &device) == noErr
+ else { return AudioObjectID(kAudioObjectUnknown) }
+ return device
+ }
+
+ private static func defaultDeviceUID(_ selector: AudioObjectPropertySelector) -> String? {
+ let device = defaultDevice(selector)
+ guard device != kAudioObjectUnknown else { return nil }
+ var address = AudioObjectPropertyAddress(
+ mSelector: kAudioDevicePropertyDeviceUID,
+ mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain)
+ var result: CFString?
+ var size = UInt32(MemoryLayout.size)
+ let status = withUnsafeMutablePointer(to: &result) {
+ AudioObjectGetPropertyData(device, &address, 0, nil, &size, $0)
+ }
+ guard status == noErr, let result else { return nil }
+ return result as String
+ }
+}