import Foundation 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 { 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 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 lastAudioAt = Date() private(set) var isRunning = false 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)", kAudioAggregateDeviceUIDKey: 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 lastAudioAt = Date() 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 sawAudio = false 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 if !sawAudio, value != 0 { sawAudio = true } } } if sawAudio { lastAudioAt = Date() } } // 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." } } }