import Foundation import Synchronization import AudioToolbox import CoreAudio import AVFoundation import OSLog private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio") /// Ein Programm, das Ton ausgibt. public struct AudioProcess: Identifiable, Equatable, Sendable { public let objectID: AudioObjectID public let pid: pid_t public let bundleID: String public let name: String public let isPlaying: Bool public var id: AudioObjectID { objectID } } /// Fängt den Ton **eines** Programms ab, verstärkt ihn und gibt ihn wieder aus. /// /// Der Weg ist der einzige, den macOS ohne Treiber anbietet: ein Process Tap /// mit `mutedWhenTapped` schaltet die ursprüngliche Ausgabe stumm und liefert /// das Signal stattdessen hier ab. Ein privates Aggregate-Device aus Tap und /// echtem Ausgabegerät gibt es nach der Bearbeitung wieder aus. /// /// **Fällt dieser Aufbau auseinander, ist das Programm still.** Deshalb wird /// jeder Fehler zum Abbau des Taps führen, nicht zu einem halben Zustand — und /// deshalb gibt es einen Wachhund gegen die bekannten Null-Puffer. final class ProcessTap { /// Vorsatz der Gerätekennungen, die Onyx selbst anlegt. static let aggregateUIDPrefix = "com.scarriffleservices.onyx.tap." let process: AudioProcess /// Wird im Echtzeitpfad gelesen. `atomic` genügt hier nicht als Sprachmittel, /// aber ein einzelner `Float` wird auf arm64 atomar geschrieben. private(set) var gain: Float = 1 /// Der höchste Ausschlag seit dem letzten Abholen — als Bitmuster, weil /// `Atomic` keine Fließkommazahlen führt. /// /// Sperrenfrei und damit im Echtzeitpfad erlaubt. Eine gewöhnliche /// Eigenschaft zu beschreiben wäre ein Wettlauf mit dem Leser, und eine /// Sperre im Audiothread ist der klassische Weg zu Aussetzern. private let peakBits = Atomic(0) /// Wann zuletzt Ton kam — in Sekunden seit dem Programmstart, damit der /// Echtzeitpfad keine `Date` anfassen muss. private let lastAudioUptime = Atomic(0) private var tapID = AudioObjectID(kAudioObjectUnknown) private var aggregateID = AudioObjectID(kAudioObjectUnknown) private var procID: AudioDeviceIOProcID? private let tapUUID = UUID() /// Wann zuletzt ein Puffer mit Inhalt kam — für den Wachhund. private(set) var isRunning = false /// Wie lange dieser Tap schon nur Stille liefert. var secondsSinceAudio: TimeInterval { let stored = lastAudioUptime.load(ordering: .relaxed) guard stored > 0 else { return 0 } return max(0, Self.uptime() - Double(stored) / 1000) } /// Der Ausschlag seit dem letzten Abruf, 0…1. Holt ihn ab und setzt zurück, /// damit die Anzeige abfällt, statt auf dem Höchstwert stehen zu bleiben. func takePeak() -> Float { Float(bitPattern: peakBits.exchange(0, ordering: .relaxed)) } private static func uptime() -> Double { Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000 } init(process: AudioProcess) { self.process = process } deinit { stop() } func setGain(_ value: Double) { gain = Float(value) } // MARK: - Aufbau func start(outputDeviceUID: String) throws { guard !isRunning else { return } let description = CATapDescription(stereoMixdownOfProcesses: [process.objectID]) description.uuid = tapUUID description.name = "Onyx – \(process.name)" description.isPrivate = true // Der Kern des Ganzen: das Original wird stumm geschaltet, das Signal // kommt nur noch hier an. Ohne das hörte man beides gleichzeitig. description.muteBehavior = .mutedWhenTapped var tap = AudioObjectID(kAudioObjectUnknown) let tapStatus = AudioHardwareCreateProcessTap(description, &tap) guard tapStatus == noErr else { throw AudioMixerError.tapFailed(tapStatus) } tapID = tap let aggregate: [String: Any] = [ kAudioAggregateDeviceNameKey: "Onyx Mixer – \(process.name)", // Kennung mit erkennbarem Vorsatz: die Geräte sind zwar privat, // das verbirgt sie aber nur vor fremden Prozessen. Onyx selbst // sieht sie in der Geräteliste und muss sie dort aussortieren // können — über den Namen ginge das nur unzuverlässig. kAudioAggregateDeviceUIDKey: Self.aggregateUIDPrefix + UUID().uuidString, // Privat: das Gerät soll nicht in der Tonauswahl des Systems // auftauchen und auch nicht von anderen Programmen gewählt werden. kAudioAggregateDeviceIsPrivateKey: true, kAudioAggregateDeviceIsStackedKey: false, kAudioAggregateDeviceMainSubDeviceKey: outputDeviceUID, kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputDeviceUID]], kAudioAggregateDeviceTapListKey: [[ kAudioSubTapUIDKey: tapUUID.uuidString, kAudioSubTapDriftCompensationKey: true, ]], ] var device = AudioObjectID(kAudioObjectUnknown) let aggregateStatus = AudioHardwareCreateAggregateDevice(aggregate as CFDictionary, &device) guard aggregateStatus == noErr else { cleanUp() throw AudioMixerError.aggregateFailed(aggregateStatus) } aggregateID = device var proc: AudioDeviceIOProcID? let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) { [weak self] _, inputData, _, outputData, _ in self?.render(input: inputData, output: outputData) } guard ioStatus == noErr, let proc else { cleanUp() throw AudioMixerError.ioProcFailed(ioStatus) } procID = proc guard AudioDeviceStart(aggregateID, proc) == noErr else { cleanUp() throw AudioMixerError.startFailed } isRunning = true lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed) log.notice("Tap aktiv für \(self.process.name, privacy: .public)") } // MARK: - Echtzeitpfad /// Läuft auf dem Audiothread. Hier darf nichts geschehen, was blockieren /// kann: keine Speicheranforderung, keine Sperre, kein Protokollieren. private func render(input: UnsafePointer, output: UnsafeMutablePointer) { let inputList = UnsafeMutableAudioBufferListPointer( UnsafeMutablePointer(mutating: input)) let outputList = UnsafeMutableAudioBufferListPointer(output) let factor = gain var peak: Float = 0 for (index, outputBuffer) in outputList.enumerated() { guard index < inputList.count, let source = inputList[index].mData, let destination = outputBuffer.mData else { continue } let count = Int(min(inputList[index].mDataByteSize, outputBuffer.mDataByteSize)) / MemoryLayout.size let inputSamples = source.assumingMemoryBound(to: Float.self) let outputSamples = destination.assumingMemoryBound(to: Float.self) for sample in 0.. 1 ? SoftClip.apply(value) : value outputSamples[sample] = limited // Der Ausschlag wird im selben Durchlauf mitgenommen: ein // zweiter Durchgang nur zum Messen wäre im Echtzeitpfad die // teuerste Art, an dieselbe Zahl zu kommen. let magnitude = abs(limited) if magnitude > peak { peak = magnitude } } } if peak > 0 { // Höchstwert behalten, bis die Anzeige ihn abholt — sonst // verschluckt ein Puffer mit Stille die Spitze davor. let previous = Float(bitPattern: peakBits.load(ordering: .relaxed)) if peak > previous { peakBits.store(peak.bitPattern, ordering: .relaxed) } lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed) } } // MARK: - Abbau func stop() { guard tapID != kAudioObjectUnknown || aggregateID != kAudioObjectUnknown else { return } cleanUp() isRunning = false } /// Immer in dieser Reihenfolge: erst anhalten, dann IOProc, dann Gerät, /// dann Tap. Umgekehrt bleibt das Programm stumm zurück, weil der Tap noch /// stummschaltet, aber nichts mehr ausgibt. private func cleanUp() { 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) } } } public enum AudioMixerError: Error, LocalizedError { case tapFailed(OSStatus) case aggregateFailed(OSStatus) case ioProcFailed(OSStatus) case startFailed case noOutputDevice public var errorDescription: String? { switch self { case .tapFailed(let status): "Der Ton dieses Programms lässt sich nicht abgreifen (\(status))." case .aggregateFailed(let status): "Das Mischgerät ließ sich nicht anlegen (\(status))." case .ioProcFailed(let status): "Die Tonverarbeitung ließ sich nicht starten (\(status))." case .startFailed: "Das Mischgerät startet nicht." case .noOutputDevice: "Kein Ausgabegerät gefunden." } } }