// Spike C3 — mehrere Aggregate-Geräte gleichzeitig auf **einem** Ausgabegerät // // Der Mixer legt je Prozess einen Tap in einem eigenen Aggregate-Gerät an. Ein // Programm wie Chrome bringt aber mehrere Prozesse mit — also mehrere // Aggregate-Geräte, die sich alle denselben Lautsprecher als Untergerät teilen. // Mit einem einzigen Prozess funktioniert der Weg nachweislich (Spike C2). // // Dieser Spike misst, was bei zwei und drei gleichzeitigen Geräten passiert: // laufen alle IOProcs, oder verhungert eines davon? // // Bauen und laufen: Spikes/build-multi-tap-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 } let wanted = CommandLine.arguments.dropFirst().first { !$0.hasPrefix("--") } let objects = readArray(AudioObjectID(kAudioObjectSystemObject), address(kAudioHardwarePropertyProcessObjectList), AudioObjectID.self) struct Candidate { let object: AudioObjectID; let bundleID: String; let playing: Bool } let candidates: [Candidate] = objects.compactMap { object in let pid = readValue(object, address(kAudioProcessPropertyPID), pid_t(0)) ?? -1 let bundle = readString(object, address(kAudioProcessPropertyBundleID)).flatMap { $0.isEmpty ? nil : $0 } ?? "pid \(pid)" let playing = (readValue(object, address(kAudioProcessPropertyIsRunningOutput), UInt32(0)) ?? 0) != 0 return Candidate(object: object, bundleID: bundle, playing: playing) } let target: Candidate? = { if let wanted { return candidates.first { $0.bundleID.localizedCaseInsensitiveContains(wanted) } } return candidates.first { $0.playing } }() guard let target else { print("Kein ausgebender Prozess."); exit(1) } guard let outputDevice = readValue(AudioObjectID(kAudioObjectSystemObject), address(kAudioHardwarePropertyDefaultOutputDevice), AudioObjectID(kAudioObjectUnknown)), let outputUID = readString(outputDevice, address(kAudioDevicePropertyDeviceUID)) else { print("Kein Ausgabegerät."); exit(1) } print("Ziel: \(target.bundleID), Ausgabe: \(outputUID)\n") /// Ein aufgebauter Weg — Tap, Gerät, IOProc. final class Path: @unchecked Sendable { let index: Int let targetObject: AudioObjectID let outputUID: String var tapID = AudioObjectID(kAudioObjectUnknown) var aggregateID = AudioObjectID(kAudioObjectUnknown) var procID: AudioDeviceIOProcID? var callbacks = 0 var peak: Float = 0 var startStatus: OSStatus = 0 init(index: Int, targetObject: AudioObjectID, outputUID: String) { self.index = index self.targetObject = targetObject self.outputUID = outputUID } func build(muted: Bool) -> Bool { let tapUUID = UUID() let description = CATapDescription(stereoMixdownOfProcesses: [targetObject]) description.uuid = tapUUID description.name = "Onyx Probe \(index)" description.isPrivate = true description.muteBehavior = muted ? .mutedWhenTapped : .unmuted let tapStatus = AudioHardwareCreateProcessTap(description, &tapID) guard tapStatus == noErr else { print(" [\(index)] Tap: \(tapStatus)"); return false } let aggregate: [String: Any] = [ kAudioAggregateDeviceNameKey: "Onyx Probe \(index)", kAudioAggregateDeviceUIDKey: "com.scarriffleservices.onyx.probe.\(index)." + UUID().uuidString, kAudioAggregateDeviceIsPrivateKey: true, kAudioAggregateDeviceIsStackedKey: false, kAudioAggregateDeviceMainSubDeviceKey: outputUID, kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputUID]], kAudioAggregateDeviceTapListKey: [[ kAudioSubTapUIDKey: tapUUID.uuidString, kAudioSubTapDriftCompensationKey: true, ]], ] let aggregateStatus = AudioHardwareCreateAggregateDevice(aggregate as CFDictionary, &aggregateID) guard aggregateStatus == noErr else { print(" [\(index)] Gerät: \(aggregateStatus)"); return false } let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&procID, aggregateID, nil) { [self] _, inputData, _, outputData, _ in let input = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: inputData)) let output = UnsafeMutableAudioBufferListPointer(outputData) callbacks += 1 for (i, outputBuffer) in output.enumerated() { guard i < input.count, let source = input[i].mData, let destination = outputBuffer.mData else { continue } let count = Int(min(input[i].mDataByteSize, outputBuffer.mDataByteSize)) / MemoryLayout.size let inputSamples = source.assumingMemoryBound(to: Float.self) let outputSamples = destination.assumingMemoryBound(to: Float.self) for s in 0.. peak { peak = abs(value) } } } } guard ioStatus == noErr, let procID else { print(" [\(index)] IOProc: \(ioStatus)"); return false } startStatus = AudioDeviceStart(aggregateID, procID) return true } func tearDown() { if let procID { AudioDeviceStop(aggregateID, procID) AudioDeviceDestroyIOProcID(aggregateID, procID) } if aggregateID != kAudioObjectUnknown { AudioHardwareDestroyAggregateDevice(aggregateID) } if tapID != kAudioObjectUnknown { AudioHardwareDestroyProcessTap(tapID) } } } func run(count: Int, muted: Bool, seconds: Double) { print("\(count) Gerät(e) gleichzeitig, \(muted ? "stumm" : "offen"):") let paths = (1...count).map { Path(index: $0, targetObject: target.object, outputUID: outputUID) } for path in paths where !path.build(muted: muted) { } Thread.sleep(forTimeInterval: seconds) for path in paths { print(" [\(path.index)] Start \(path.startStatus), Aufrufe \(path.callbacks), Spitze \(path.peak)") } for path in paths { path.tearDown() } print("") } run(count: 1, muted: true, seconds: 4) run(count: 2, muted: true, seconds: 4) run(count: 3, muted: true, seconds: 4) print("→ Alles abgebaut.")