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 + } +}