Phase 0: Repo-Gerüst und vier Verifikations-Spikes

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.
This commit is contained in:
Guido Schmit
2026-08-10 16:47:54 +02:00
commit 41ee3cc174
11 changed files with 1085 additions and 0 deletions

230
Spikes/audiotap-probe.swift Normal file
View File

@@ -0,0 +1,230 @@
// 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.")
}