// Spike C2 — der **Wiedergabeweg** des Mixers // // Spike C hat nur zugehört (`muteBehavior = .unmuted`) und nie etwas ausgegeben. // Genau der ungeprüfte Teil ist der, der jetzt still bleibt: Tap mit // `.mutedWhenTapped` schaltet das Programm stumm, und das Aggregate-Device soll // das Signal wieder ausgeben. Hörbar ist nichts. // // Dieser Spike baut denselben Weg nach und misst, was in der IOProc ankommt: // wie viele Puffer, wie viele Kanäle, welche Größe — auf beiden Seiten. // // Bauen und laufen: Spikes/build-mixer-path-probe.sh [Bundle-ID-Fragment] import Foundation import AppKit import AVFoundation import CoreAudio import AudioToolbox func address(_ selector: AudioObjectPropertySelector, _ scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal) -> AudioObjectPropertyAddress { AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: kAudioObjectPropertyElementMain) } func readArray(_ object: AudioObjectID, _ addr: AudioObjectPropertyAddress, _ type: T.Type) -> [T] { var addr = addr var size: UInt32 = 0 guard AudioObjectGetPropertyDataSize(object, &addr, 0, nil, &size) == noErr, size > 0 else { return [] } let count = Int(size) / MemoryLayout.stride let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: MemoryLayout.alignment) defer { raw.deallocate() } guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, raw) == noErr else { return [] } return Array(UnsafeBufferPointer(start: raw.assumingMemoryBound(to: T.self), count: count)) } func readValue(_ object: AudioObjectID, _ addr: AudioObjectPropertyAddress, _ initial: T) -> T? { var addr = addr var value = initial var size = UInt32(MemoryLayout.size) guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &value) == noErr else { return nil } return value } func readString(_ object: AudioObjectID, _ addr: AudioObjectPropertyAddress) -> String? { var addr = addr var cf: CFString? var size = UInt32(MemoryLayout.size) let status = withUnsafeMutablePointer(to: &cf) { AudioObjectGetPropertyData(object, &addr, 0, nil, &size, $0) } guard status == noErr, let cf else { return nil } return cf as String } /// Die Kanalaufteilung eines Geräts in einem Bereich. func streamLayout(_ device: AudioObjectID, scope: AudioObjectPropertyScope) -> String { var addr = address(kAudioDevicePropertyStreamConfiguration, scope) var size: UInt32 = 0 guard AudioObjectGetPropertyDataSize(device, &addr, 0, nil, &size) == noErr, size > 0 else { return "—" } let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: 16) defer { raw.deallocate() } guard AudioObjectGetPropertyData(device, &addr, 0, nil, &size, raw) == noErr else { return "—" } let list = UnsafeMutableAudioBufferListPointer( raw.assumingMemoryBound(to: AudioBufferList.self)) let parts = list.map { "\($0.mNumberChannels) Kan." } return parts.isEmpty ? "keine" : "\(list.count) Puffer: " + parts.joined(separator: ", ") } // MARK: - Zielprozess let wanted = CommandLine.arguments.dropFirst().first { !$0.hasPrefix("--") } struct Candidate { let object: AudioObjectID let bundleID: String let pid: pid_t let playing: Bool } let objects = readArray(AudioObjectID(kAudioObjectSystemObject), address(kAudioHardwarePropertyProcessObjectList), AudioObjectID.self) let candidates: [Candidate] = objects.compactMap { object in let pid = readValue(object, address(kAudioProcessPropertyPID), pid_t(0)) ?? -1 // Auch Prozesse ohne Bundle-Kennung: `afplay` ist als Testquelle genau // deshalb brauchbar, weil es ein nacktes Programm ohne Bundle ist. let bundle = readString(object, address(kAudioProcessPropertyBundleID)).flatMap { $0.isEmpty ? nil : $0 } ?? (NSRunningApplication(processIdentifier: pid)?.localizedName ?? "pid \(pid)") let playing = (readValue(object, address(kAudioProcessPropertyIsRunningOutput), UInt32(0)) ?? 0) != 0 return Candidate(object: object, bundleID: bundle, pid: pid, playing: playing) } print("Audio-Prozesse, die gerade ausgeben:") for candidate in candidates where candidate.playing { print(" \(candidate.bundleID) pid \(candidate.pid) obj \(candidate.object)") } let target: Candidate? = { if let wanted { return candidates.first { $0.bundleID.caseInsensitiveCompare(wanted) == .orderedSame } ?? candidates.first { $0.bundleID.localizedCaseInsensitiveContains(wanted) } } return candidates.first { $0.playing } }() guard let target else { print("\nKein ausgebender Prozess gefunden. Musik oder ein Video starten und erneut laufen lassen.") exit(1) } print("\n→ Ziel: \(target.bundleID) (obj \(target.object))") // MARK: - Ausgabegerät guard let outputDevice = readValue(AudioObjectID(kAudioObjectSystemObject), address(kAudioHardwarePropertyDefaultOutputDevice), AudioObjectID(kAudioObjectUnknown)), let outputUID = readString(outputDevice, address(kAudioDevicePropertyDeviceUID)) else { print("Kein Standard-Ausgabegerät.") exit(1) } let outputName = readString(outputDevice, address(kAudioObjectPropertyName)) ?? "?" print("→ Ausgabegerät: \(outputName) [\(outputUID)] obj \(outputDevice)") print(" Eingänge: \(streamLayout(outputDevice, scope: kAudioObjectPropertyScopeInput))") print(" Ausgänge: \(streamLayout(outputDevice, scope: kAudioObjectPropertyScopeOutput))") // MARK: - Messung final class Stats: @unchecked Sendable { var callbacks = 0 var inputChannels: [UInt32] = [] var outputChannels: [UInt32] = [] var inputBytes: [UInt32] = [] var outputBytes: [UInt32] = [] var inputPeak: Float = 0 var copiedSamples = 0 var nilInput = 0 var nilOutput = 0 } /// Baut den Weg einmal auf, misst und baut ihn wieder ab. /// /// - Parameter muted: der Unterschied, um den es geht. `false` heißt zuhören /// ohne einzugreifen (so lief Spike C), `true` heißt: das Original wird stumm /// geschaltet und der Ton soll nur noch aus dieser IOProc kommen. @discardableResult func measure(label: String, muted: Bool, global: Bool, seconds: Double, passthrough: Bool = true) -> Stats? { let stats = Stats() let tapUUID = UUID() let description = global ? CATapDescription(stereoGlobalTapButExcludeProcesses: []) : CATapDescription(stereoMixdownOfProcesses: [target.object]) description.uuid = tapUUID description.name = "Onyx Probe" description.isPrivate = true description.muteBehavior = muted ? .mutedWhenTapped : .unmuted var tapID = AudioObjectID(kAudioObjectUnknown) let tapStatus = AudioHardwareCreateProcessTap(description, &tapID) guard tapStatus == noErr else { print("[\(label)] Tap fehlgeschlagen: \(tapStatus)") return nil } defer { AudioHardwareDestroyProcessTap(tapID) } var tapFormat = AudioStreamBasicDescription() var formatSize = UInt32(MemoryLayout.size) var formatAddress = address(kAudioTapPropertyFormat) if AudioObjectGetPropertyData(tapID, &formatAddress, 0, nil, &formatSize, &tapFormat) == noErr { print("[\(label)] Tap-Format: \(Int(tapFormat.mSampleRate)) Hz, " + "\(tapFormat.mChannelsPerFrame) Kanäle, Flags \(tapFormat.mFormatFlags)") } let aggregate: [String: Any] = [ kAudioAggregateDeviceNameKey: "Onyx Probe", kAudioAggregateDeviceUIDKey: "com.scarriffleservices.onyx.probe." + UUID().uuidString, kAudioAggregateDeviceIsPrivateKey: true, kAudioAggregateDeviceIsStackedKey: false, kAudioAggregateDeviceMainSubDeviceKey: outputUID, kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputUID]], kAudioAggregateDeviceTapListKey: [[ kAudioSubTapUIDKey: tapUUID.uuidString, kAudioSubTapDriftCompensationKey: true, ]], ] var aggregateID = AudioObjectID(kAudioObjectUnknown) let aggregateStatus = AudioHardwareCreateAggregateDevice(aggregate as CFDictionary, &aggregateID) guard aggregateStatus == noErr else { print("[\(label)] Aggregate fehlgeschlagen: \(aggregateStatus)") return nil } defer { AudioHardwareDestroyAggregateDevice(aggregateID) } print("[\(label)] Aggregate \(aggregateID) — Eingänge \(streamLayout(aggregateID, scope: kAudioObjectPropertyScopeInput)), Ausgänge \(streamLayout(aggregateID, scope: kAudioObjectPropertyScopeOutput))") var procID: AudioDeviceIOProcID? let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&procID, aggregateID, nil) { _, inputData, _, outputData, _ in let input = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: inputData)) let output = UnsafeMutableAudioBufferListPointer(outputData) stats.callbacks += 1 if stats.inputChannels.isEmpty { stats.inputChannels = input.map(\.mNumberChannels) stats.outputChannels = output.map(\.mNumberChannels) stats.inputBytes = input.map(\.mDataByteSize) stats.outputBytes = output.map(\.mDataByteSize) } for (index, outputBuffer) in output.enumerated() { guard index < input.count else { continue } guard let source = input[index].mData else { stats.nilInput += 1; continue } guard let destination = outputBuffer.mData else { stats.nilOutput += 1; continue } let count = Int(min(input[index].mDataByteSize, outputBuffer.mDataByteSize)) / MemoryLayout.size let inputSamples = source.assumingMemoryBound(to: Float.self) let outputSamples = destination.assumingMemoryBound(to: Float.self) for sample in 0.. stats.inputPeak { stats.inputPeak = magnitude } } stats.copiedSamples += count } } guard ioStatus == noErr, let procID else { print("[\(label)] IOProc fehlgeschlagen: \(ioStatus)") return nil } defer { AudioDeviceDestroyIOProcID(aggregateID, procID) } let startStatus = AudioDeviceStart(aggregateID, procID) print("[\(label)] läuft \(Int(seconds)) s (Start \(startStatus))") Thread.sleep(forTimeInterval: seconds) AudioDeviceStop(aggregateID, procID) print("[\(label)] Aufrufe \(stats.callbacks), Samples \(stats.copiedSamples), " + "Spitze \(stats.inputPeak), Eingang \(stats.inputChannels)/\(stats.inputBytes), " + "Ausgang \(stats.outputChannels)/\(stats.outputBytes), " + "leer \(stats.nilInput)/\(stats.nilOutput)") return stats } // Drei Durchgänge, die sich gegenseitig einordnen: // // 1. nur zuhören — so lief Spike C. Kommt hier nichts an, liegt es nicht am // Stummschalten, sondern daran, dass der Tap den Prozess nicht erwischt. // 2. stummschalten und ausgeben — das ist der Weg des Mixers. // 3. alles abgreifen statt nur einen Prozess — trennt „dieser Prozess" von // „Taps gehen auf dieser Maschine gerade gar nicht". // Mit `--global-only` bleibt der Zielprozess unangetastet: dann misst der // Spike nur, was am Ausgabegerät tatsächlich ankommt — brauchbar, um Onyx // selbst zu vermessen, ohne ihm mit einem zweiten Tap dazwischenzufunken. let globalOnly = CommandLine.arguments.contains("--global-only") // `--listen-only`: nur zuhören, nichts stummschalten — so lässt sich messen, // was ein **anderes** Programm (etwa Onyx selbst) gerade ausgibt. let listenOnly = CommandLine.arguments.contains("--listen-only") // `--muted-only`: nur der Weg, den der Mixer geht — stummschalten und selbst // ausgeben. Damit lässt sich messen, ob dabei überhaupt etwas hörbar bleibt. let mutedOnly = CommandLine.arguments.contains("--muted-only") print("") if mutedOnly { measure(label: "2 stumm + ausgeben", muted: true, global: false, seconds: 10) } else if !globalOnly { measure(label: "1 nur zuhören", muted: false, global: false, seconds: 5, passthrough: false) print("") if !listenOnly { measure(label: "2 stumm + ausgeben", muted: true, global: false, seconds: 5) print("") } } if !listenOnly { measure(label: "3 global, nur zuhören", muted: false, global: true, seconds: 5, passthrough: false) } print("\n→ Alles abgebaut, Wiedergabe wieder normal.")