Vier Annahmen des Plans vor jedem Implementierungscode geprüft:
A (SMC): Lüftersteuerung bestätigt — F0md/F1md (klein!) als Auto/Manuell-
Umschalter, F0Tg als Ziel, Grenzen aus F0Mn/F0Mx. SMC-Integer sind
little-endian, nicht big-endian wie im verbreiteten Intel-Beispielcode.
Ladelimit: CHWA/CH0B/CH0C/bfE0/bfF0 existieren auf Mac17,9 nicht.
Key noch nicht identifiziert, deshalb smc-diff.sh für eine
Differenzmessung ohne jeden Schreibzugriff.
B (Media): mediaremote-adapter läuft unter 26.6.1, aus dem Quellcode gebaut.
Liefert vollständige Metadaten inkl. Cover aus Google Chrome — der
Fall, den AppleScript nicht erreicht.
C (Audio): Process Tap + privates Aggregate-Device liefern echte Samples
(469 Callbacks/5 s, 0 stille Puffer). Konflikt gefunden: SoundSource
6.1.1 mit ARK.driver ist bereits installiert.
D (Group): App-Group-Container ist aus einem nicht-sandboxed Prozess erreichbar.
Damit trägt die Sandbox-Entscheidung, die Bridge-App entfällt.
Spikes sind Wegwerfcode zur Verifikation, kein Produktionscode.
231 lines
8.5 KiB
Swift
231 lines
8.5 KiB
Swift
// Spike C — Core Audio Process Taps auf M5 Pro / macOS 26
|
|
//
|
|
// Fragen:
|
|
// 1. Liefert kAudioHardwarePropertyProcessObjectList die Audio-Prozesse?
|
|
// 2. Lässt sich pro Prozess ein Tap erzeugen (AudioHardwareCreateProcessTap)?
|
|
// 3. Lässt sich ein privates Aggregate-Device mit diesem Tap bauen?
|
|
// 4. Liefert ein IOProc darauf tatsächlich Samples?
|
|
//
|
|
// Der Tap wird mit muteBehavior = .unmuted angelegt — dieser Spike hört nur zu und
|
|
// verändert NICHTS an der Wiedergabe. Der stummschaltende Modus, den der spätere
|
|
// Mixer braucht, wird hier bewusst nicht benutzt.
|
|
//
|
|
// Bauen: Spikes/build-audiotap-probe.sh
|
|
|
|
import Foundation
|
|
import AppKit
|
|
import AVFoundation
|
|
import CoreAudio
|
|
import AudioToolbox
|
|
|
|
// MARK: - Property-Hilfen
|
|
|
|
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? = nil
|
|
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 system = AudioObjectID(kAudioObjectSystemObject)
|
|
|
|
print("Core-Audio-Process-Tap-Probe")
|
|
print("macOS \(ProcessInfo.processInfo.operatingSystemVersionString)")
|
|
print(String(repeating: "─", count: 68))
|
|
|
|
// MARK: - 1. Audio-Prozesse auflisten
|
|
|
|
let processes = readArray(system, address(kAudioHardwarePropertyProcessObjectList), AudioObjectID.self)
|
|
print("\n1. Audio-Prozesse: \(processes.count)")
|
|
|
|
struct AudioProc {
|
|
let objectID: AudioObjectID
|
|
let pid: pid_t
|
|
let bundleID: String
|
|
let running: Bool
|
|
var name: String {
|
|
guard let app = NSRunningApplication(processIdentifier: pid) else { return bundleID }
|
|
return app.localizedName ?? bundleID
|
|
}
|
|
}
|
|
|
|
var procs: [AudioProc] = []
|
|
for obj in processes {
|
|
let pid = readValue(obj, address(kAudioProcessPropertyPID), pid_t(0)) ?? -1
|
|
let bundle = readString(obj, address(kAudioProcessPropertyBundleID)) ?? "—"
|
|
let running = (readValue(obj, address(kAudioProcessPropertyIsRunningOutput), UInt32(0)) ?? 0) != 0
|
|
procs.append(AudioProc(objectID: obj, pid: pid, bundleID: bundle, running: running))
|
|
}
|
|
|
|
for p in procs.sorted(by: { $0.running && !$1.running }) {
|
|
let mark = p.running ? "▶ SPIELT AB" : " still"
|
|
print(" \(mark) pid \(String(p.pid).padding(toLength: 6, withPad: " ", startingAt: 0)) \(p.bundleID)")
|
|
}
|
|
|
|
guard let target = procs.first(where: { $0.running }) else {
|
|
print("\nKein Prozess gibt gerade Audio aus — bitte etwas abspielen und erneut starten.")
|
|
exit(2)
|
|
}
|
|
print("\n Ziel: \(target.bundleID) (pid \(target.pid))")
|
|
|
|
// MARK: - 2. Tap erzeugen
|
|
|
|
print("\n2. Process Tap")
|
|
let tapDescription = CATapDescription(stereoMixdownOfProcesses: [target.objectID])
|
|
tapDescription.uuid = UUID()
|
|
tapDescription.name = "Onyx Spike Tap"
|
|
tapDescription.isPrivate = true
|
|
// Bewusst NICHT .mutedWhenTapped — dieser Spike verändert die Wiedergabe nicht.
|
|
tapDescription.muteBehavior = .unmuted
|
|
|
|
var tapID = AudioObjectID(kAudioObjectUnknown)
|
|
let tapStatus = AudioHardwareCreateProcessTap(tapDescription, &tapID)
|
|
guard tapStatus == noErr else {
|
|
print(" FEHLGESCHLAGEN: OSStatus \(tapStatus)")
|
|
if tapStatus == 1751737219 { // 'nope' — kAudioHardwareIllegalOperationError
|
|
print(" → Vermutlich fehlt die TCC-Berechtigung für Systemaudioaufnahme.")
|
|
print(" Systemeinstellungen → Datenschutz & Sicherheit → Audioaufnahme")
|
|
}
|
|
exit(1)
|
|
}
|
|
print(" erzeugt, Tap-Objekt \(tapID)")
|
|
|
|
defer { AudioHardwareDestroyProcessTap(tapID) }
|
|
|
|
let tapFormat = readValue(tapID, address(kAudioTapPropertyFormat), AudioStreamBasicDescription())
|
|
if let f = tapFormat {
|
|
print(" Format: \(Int(f.mSampleRate)) Hz, \(f.mChannelsPerFrame) Kanäle, \(f.mBitsPerChannel) bit")
|
|
}
|
|
|
|
// MARK: - 3. Privates Aggregate-Device
|
|
|
|
print("\n3. Privates Aggregate-Device")
|
|
guard let outputDevice = readValue(system, address(kAudioHardwarePropertyDefaultOutputDevice),
|
|
AudioObjectID(kAudioObjectUnknown)),
|
|
let outputUID = readString(outputDevice, address(kAudioDevicePropertyDeviceUID)) else {
|
|
print(" Standard-Ausgabegerät nicht ermittelbar")
|
|
exit(1)
|
|
}
|
|
print(" Ausgabegerät: \(readString(outputDevice, address(kAudioObjectPropertyName)) ?? outputUID)")
|
|
|
|
let aggregateUID = UUID().uuidString
|
|
let description: [String: Any] = [
|
|
kAudioAggregateDeviceNameKey: "Onyx Spike Aggregate",
|
|
kAudioAggregateDeviceUIDKey: aggregateUID,
|
|
kAudioAggregateDeviceIsPrivateKey: true,
|
|
kAudioAggregateDeviceIsStackedKey: false,
|
|
kAudioAggregateDeviceMainSubDeviceKey: outputUID,
|
|
kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputUID]],
|
|
kAudioAggregateDeviceTapListKey: [[
|
|
kAudioSubTapUIDKey: tapDescription.uuid.uuidString,
|
|
kAudioSubTapDriftCompensationKey: true,
|
|
]],
|
|
]
|
|
|
|
var aggregateID = AudioObjectID(kAudioObjectUnknown)
|
|
let aggStatus = AudioHardwareCreateAggregateDevice(description as CFDictionary, &aggregateID)
|
|
guard aggStatus == noErr else {
|
|
print(" FEHLGESCHLAGEN: OSStatus \(aggStatus)")
|
|
exit(1)
|
|
}
|
|
print(" erzeugt, Device \(aggregateID), UID \(aggregateUID)")
|
|
defer { AudioHardwareDestroyAggregateDevice(aggregateID) }
|
|
|
|
// MARK: - 4. IOProc — kommen Samples an?
|
|
|
|
print("\n4. IOProc (5 s messen)")
|
|
|
|
final class Meter: @unchecked Sendable {
|
|
var callbacks = 0
|
|
var frames = 0
|
|
var peak: Float = 0
|
|
var silentBuffers = 0
|
|
let lock = NSLock()
|
|
}
|
|
let meter = Meter()
|
|
|
|
var procID: AudioDeviceIOProcID?
|
|
let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&procID, aggregateID, nil) {
|
|
_, inputData, _, _, _ in
|
|
let buffers = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: inputData))
|
|
var localPeak: Float = 0
|
|
var frameCount = 0
|
|
for buffer in buffers {
|
|
guard let data = buffer.mData else { continue }
|
|
let count = Int(buffer.mDataByteSize) / MemoryLayout<Float>.size
|
|
frameCount += count
|
|
let samples = data.assumingMemoryBound(to: Float.self)
|
|
for i in 0..<count { localPeak = max(localPeak, abs(samples[i])) }
|
|
}
|
|
meter.lock.lock()
|
|
meter.callbacks += 1
|
|
meter.frames += frameCount
|
|
meter.peak = max(meter.peak, localPeak)
|
|
if localPeak == 0 { meter.silentBuffers += 1 }
|
|
meter.lock.unlock()
|
|
}
|
|
|
|
guard ioStatus == noErr, let procID else {
|
|
print(" IOProc-Erzeugung fehlgeschlagen: \(ioStatus)")
|
|
exit(1)
|
|
}
|
|
defer { AudioDeviceDestroyIOProcID(aggregateID, procID) }
|
|
|
|
guard AudioDeviceStart(aggregateID, procID) == noErr else {
|
|
print(" AudioDeviceStart fehlgeschlagen")
|
|
exit(1)
|
|
}
|
|
Thread.sleep(forTimeInterval: 5)
|
|
AudioDeviceStop(aggregateID, procID)
|
|
|
|
meter.lock.lock()
|
|
let (cb, fr, pk, silent) = (meter.callbacks, meter.frames, meter.peak, meter.silentBuffers)
|
|
meter.lock.unlock()
|
|
|
|
print(" Callbacks: \(cb) (\(cb / 5)/s)")
|
|
print(" Frames: \(fr)")
|
|
print(" Spitzenpegel: \(String(format: "%.4f", pk))")
|
|
print(" Stille Puffer: \(silent) von \(cb)")
|
|
|
|
print("\n" + String(repeating: "─", count: 68))
|
|
if cb > 0 && pk > 0 {
|
|
print("ERGEBNIS: Tap liefert echte Samples. Per-App-Mixer ist umsetzbar.")
|
|
} else if cb > 0 {
|
|
print("ERGEBNIS: Callbacks kommen, aber nur Stille — spielte das Ziel wirklich ab?")
|
|
} else {
|
|
print("ERGEBNIS: keine Callbacks. Aggregate-Device liefert nichts.")
|
|
}
|