Mixer war still: er hat sich selbst abgegriffen
Der Mixer gibt den Ton der bearbeiteten Programme selbst aus — und steht damit als Tonquelle in seiner eigenen Liste. Eine Zeile wird geregelt, sobald man ihren Regler anfasst. Ein Klick auf Onyx' eigene Zeile genügte also. Der Selbst-Tap läuft wie jeder andere mit mutedWhenTapped und schaltet damit Onyx' gesamte Ausgabe stumm — die Wiedergabe *aller* bearbeiteten Programme. Völlige Stille, während die Pegelanzeigen weiter ausschlagen, weil die Taps ja Signal bekommen. Und der Zustand stand in den Einstellungen, überlebte also jeden Neustart. Gemessen (Spikes/mixer-path-probe.swift): Onyx' Ausgabepegel klettert von 0,388 auf 2,833, sobald er sich selbst bearbeitet. Der Weg an sich ist in Ordnung — Tap, Aggregate-Gerät und IOProc liefern einzeln wie zu dritt volle Pegel, das war der Hauptverdacht und ist widerlegt. Onyx erscheint jetzt nicht mehr in der eigenen Liste, lässt sich nicht auswählen, und ein gespeicherter Eintrag wird beim Laden entfernt und zurückgeschrieben. Dazu ein zweiter Fund aus derselben Ecke: setVolume und setMuted griffen auf taps[bundleID] zu. Die Taps liegen aber unter Kennung#Prozessnummer, weil ein Programm mehrere Tonquellen haben kann — der Zugriff ging immer ins Leere. Der Regler wirkte erst, wenn die Auffrischung drei Sekunden später nachzog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
180
Spikes/multi-tap-probe.swift
Normal file
180
Spikes/multi-tap-probe.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
// 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<T>(_ 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<T>.stride
|
||||
let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size),
|
||||
alignment: MemoryLayout<T>.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<T>(_ object: AudioObjectID, _ addr: AudioObjectPropertyAddress, _ initial: T) -> T? {
|
||||
var addr = addr
|
||||
var value = initial
|
||||
var size = UInt32(MemoryLayout<T>.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<CFString?>.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<Float>.size
|
||||
let inputSamples = source.assumingMemoryBound(to: Float.self)
|
||||
let outputSamples = destination.assumingMemoryBound(to: Float.self)
|
||||
for s in 0..<count {
|
||||
let value = inputSamples[s]
|
||||
outputSamples[s] = value
|
||||
if abs(value) > 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.")
|
||||
Reference in New Issue
Block a user