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