diff --git a/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift b/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift index 200c561..2e33d50 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift @@ -50,6 +50,9 @@ public final class AudioMixer { } } + /// Die eigene Bundle-Kennung — der Mixer darf sich nicht selbst abgreifen. + private static let ownBundleID = Bundle.main.bundleIdentifier + private enum Keys { static let volumes = "onyx.audio.volumes" static let muted = "onyx.audio.muted" @@ -62,8 +65,18 @@ public final class AudioMixer { let volumes = defaults.dictionary(forKey: Keys.volumes) as? [String: Double] ?? [:] let muted = defaults.stringArray(forKey: Keys.muted) ?? [] store = VolumeStore(volumes: volumes, muted: muted) - managed = Set(defaults.stringArray(forKey: Keys.managed) ?? []) + // Beim Laden aussieben statt nur beim Anzeigen: hat sich der Mixer + // einmal selbst in die Auswahl geschrieben, säße die Rückkopplung sonst + // dauerhaft in den Einstellungen und käme bei jedem Start zurück. + let stored = defaults.stringArray(forKey: Keys.managed) ?? [] + managed = Set(stored.filter { + !ProcessVisibility.isExcluded(bundleID: $0, ownBundleID: Self.ownBundleID) + }) startsAutomatically = defaults.bool(forKey: Keys.autostart) + // Gleich zurückschreiben, wenn etwas herausgefallen ist: sonst steht der + // alte Eintrag weiter in den Einstellungen und wirkt bei der nächsten + // Fehlersuche wie ein Zustand, den es noch gibt. + if managed.count != stored.count { defaults.set(Array(managed), forKey: Keys.managed) } } /// Beim Programmstart aufrufen. Läuft nur an, wenn das Häkchen gesetzt ist. @@ -273,6 +286,8 @@ public final class AudioMixer { } public func setManaged(_ isManaged: Bool, for bundleID: String) { + guard !ProcessVisibility.isExcluded(bundleID: bundleID, + ownBundleID: Self.ownBundleID) else { return } if isManaged { managed.insert(bundleID) } else { managed.remove(bundleID) } persist() syncTaps() @@ -283,16 +298,30 @@ public final class AudioMixer { public func setVolume(_ volume: Double, for bundleID: String) { store.set(volume, for: bundleID) - taps[bundleID]?.setGain(store.effectiveGain(for: bundleID)) + applyGain(for: bundleID) persist() } public func setMuted(_ isMuted: Bool, for bundleID: String) { store.setMuted(isMuted, for: bundleID) - taps[bundleID]?.setGain(store.effectiveGain(for: bundleID)) + applyGain(for: bundleID) persist() } + /// Gibt die neue Verstärkung an **alle** Taps des Programms weiter. + /// + /// Vorher stand hier `taps[bundleID]` — die Taps liegen aber unter + /// `Kennung#Prozessnummer`, weil ein Programm mehrere Tonquellen haben kann. + /// Der Zugriff ging deshalb immer ins Leere, und der Regler wirkte erst, + /// wenn die Auffrischung drei Sekunden später von selbst nachzog. + private func applyGain(for bundleID: String) { + let gain = store.effectiveGain(for: bundleID) + for tap in taps.values + where ProcessNaming.parentBundleID(for: tap.process.bundleID) == bundleID { + tap.setGain(gain) + } + } + private func persist() { defaults.set(store.snapshot, forKey: Keys.volumes) defaults.set(store.mutedIdentifiers, forKey: Keys.muted) @@ -303,6 +332,7 @@ public final class AudioMixer { @MainActor static func currentProcesses() -> [AudioProcess] { + let ownBundleID = Self.ownBundleID var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyProcessObjectList, mScope: kAudioObjectPropertyScopeGlobal, @@ -328,10 +358,8 @@ public final class AudioMixer { !bundleID.isEmpty else { return nil } let pid = value(object, kAudioProcessPropertyPID, pid_t(0)) ?? -1 - // Systemdienste wie coreaudiod oder die Sprachausgabe gehören nicht - // in eine Liste, in der man „Spotify leiser" sucht. - guard !bundleID.hasPrefix("com.apple.audio"), - !bundleID.hasPrefix("com.apple.core") else { return nil } + guard !ProcessVisibility.isExcluded(bundleID: bundleID, + ownBundleID: ownBundleID) else { return nil } let name = ProcessNaming.displayName(bundleID: bundleID, pid: pid) let playing = (value(object, kAudioProcessPropertyIsRunningOutput, UInt32(0)) ?? 0) != 0 diff --git a/Packages/OnyxKit/Sources/AudioProvider/ProcessNaming.swift b/Packages/OnyxKit/Sources/AudioProvider/ProcessNaming.swift index 36e5297..ccd4c75 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/ProcessNaming.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/ProcessNaming.swift @@ -110,3 +110,22 @@ public enum ProcessVisibility { return seconds <= gracePeriod } } + +public extension ProcessVisibility { + + /// Programme, die gar nicht erst in der Liste auftauchen. + /// + /// **Onyx selbst gehört dazu**, und nicht nur der Ordnung halber: sobald der + /// Mixer ein Programm bearbeitet, gibt *er* dessen Ton aus — und steht damit + /// als Tonquelle in seiner eigenen Liste. Greift er sich dort ab, hört er + /// seine eigene Ausgabe wieder mit und gibt sie erneut aus. Gemessen klettert + /// der Pegel dabei binnen Sekunden von 0,37 auf 2,83: eine Rückkopplung, die + /// man nicht bauen, sondern ausschließen muss. + /// + /// Dazu die Systemdienste: `coreaudiod` und die Sprachausgabe sind in einer + /// Liste, in der man „Spotify leiser" sucht, nur Rauschen. + static func isExcluded(bundleID: String, ownBundleID: String?) -> Bool { + if let ownBundleID, !ownBundleID.isEmpty, bundleID == ownBundleID { return true } + return bundleID.hasPrefix("com.apple.audio") || bundleID.hasPrefix("com.apple.core") + } +} diff --git a/Packages/OnyxKit/Tests/AudioProviderTests/MixerExclusionTests.swift b/Packages/OnyxKit/Tests/AudioProviderTests/MixerExclusionTests.swift new file mode 100644 index 0000000..e537e9a --- /dev/null +++ b/Packages/OnyxKit/Tests/AudioProviderTests/MixerExclusionTests.swift @@ -0,0 +1,40 @@ +import Testing +@testable import AudioProvider + +@Suite("Wer nicht in den Mixer gehört") +struct MixerExclusionTests { + + private let own = "com.scarriffleservices.onyx" + + @Test("Onyx taucht in seiner eigenen Liste nicht auf") + func ownProcessIsHidden() { + // Der Mixer gibt den Ton der bearbeiteten Programme selbst aus und wird + // dadurch zur Tonquelle. Stünde er in der eigenen Liste, ließe er sich + // abgreifen — und hörte seine eigene Ausgabe wieder mit. + #expect(ProcessVisibility.isExcluded(bundleID: own, ownBundleID: own)) + } + + @Test("Fremde Programme bleiben sichtbar") + func othersRemain() { + for bundleID in ["com.google.Chrome", "org.mozilla.firefox", + "com.spotify.client", "com.apple.Music"] { + #expect(!ProcessVisibility.isExcluded(bundleID: bundleID, ownBundleID: own)) + } + } + + @Test("Systemdienste bleiben draußen") + func systemServicesStayOut() { + #expect(ProcessVisibility.isExcluded(bundleID: "com.apple.audio.coreaudiod", + ownBundleID: own)) + #expect(ProcessVisibility.isExcluded(bundleID: "com.apple.coremedia", + ownBundleID: own)) + } + + @Test("Ohne eigene Kennung wird nichts fälschlich ausgeschlossen") + func missingOwnIdentifierExcludesNothingExtra() { + // Im Test gibt es kein App-Bundle. Das darf nicht dazu führen, dass + // plötzlich Programme mit leerer Kennung verschwinden. + #expect(!ProcessVisibility.isExcluded(bundleID: "com.google.Chrome", ownBundleID: nil)) + #expect(!ProcessVisibility.isExcluded(bundleID: "", ownBundleID: "")) + } +} diff --git a/Spikes/build-mixer-path-probe.sh b/Spikes/build-mixer-path-probe.sh new file mode 100755 index 0000000..158b062 --- /dev/null +++ b/Spikes/build-mixer-path-probe.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Baut Spike C2 (Wiedergabeweg des Mixers) und lässt ihn laufen. +# +# Aufruf: Spikes/build-mixer-path-probe.sh [Bundle-ID-Fragment] +# Ohne Argument nimmt der Spike den ersten Prozess, der gerade Ton ausgibt. +set -euo pipefail + +cd "$(dirname "$0")/.." +export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer + +OUT=/tmp/mixer-path-probe +IDENTITY="${IDENTITY:-Apple Development: guidoschmit@guidoschmit.com (F586278766)}" + +echo "→ kompiliere" +xcrun swiftc -O Spikes/mixer-path-probe.swift -o "$OUT" + +echo "→ signiere" +codesign --force --options runtime --sign "$IDENTITY" "$OUT" + +echo +"$OUT" "$@" diff --git a/Spikes/mixer-path-probe.swift b/Spikes/mixer-path-probe.swift new file mode 100644 index 0000000..b2f00ea --- /dev/null +++ b/Spikes/mixer-path-probe.swift @@ -0,0 +1,286 @@ +// 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.") diff --git a/Spikes/multi-tap-probe.swift b/Spikes/multi-tap-probe.swift new file mode 100644 index 0000000..21af3db --- /dev/null +++ b/Spikes/multi-tap-probe.swift @@ -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(_ 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.") diff --git a/docs/spikes/C-audiotap.md b/docs/spikes/C-audiotap.md index be45ee6..f9af9d9 100644 --- a/docs/spikes/C-audiotap.md +++ b/docs/spikes/C-audiotap.md @@ -62,3 +62,58 @@ Vor Phase 7 zu klären. Bis dahin ist der Befund neutral festgehalten, nicht ent genannt wurden. Für die Notch-Shell in Phase 1 heißt das: mindestens eine davon greift denselben Bildschirmbereich ab. Für die Entwicklung sollten sie beendet sein, sonst überlagern sich Hover-Zonen und die Panels streiten sich um dieselbe Fläche. + +--- + +# Spike C2 + C3 — der Wiedergabeweg (13.08.2026) + +Spike C hat nur zugehört: `muteBehavior = .unmuted`, kein einziges Sample zurück ans +Gerät. Genau der ungeprüfte Teil war der, der im Betrieb still blieb. Werkzeuge: +`Spikes/mixer-path-probe.swift` und `Spikes/multi-tap-probe.swift`, Testquelle ein +eigens gebautes App-Bundle mit 440-Hz-Dauerton (`/tmp/OnyxTone.app`). + +## Der Weg selbst ist in Ordnung + +| Messung | Ergebnis | +|---|---| +| Tap `.unmuted`, ein Prozess | 469 Aufrufe/5 s, Spitze 0,366 | +| Tap `.mutedWhenTapped` + Ausgabe über das Aggregate-Gerät | 469 Aufrufe/5 s, Spitze 0,366 | +| Puffergeometrie | Eingang 1 Puffer / 2 Kanäle / 4096 Byte, Ausgang identisch | +| Zwei Aggregate-Geräte gleichzeitig auf demselben Lautsprecher | beide 375+ Aufrufe, beide Spitze 0,366 | +| Drei Aggregate-Geräte gleichzeitig | alle drei 375+ Aufrufe, alle Spitze 0,366 | + +Mehrere Geräte auf einem Ausgabegerät waren der Hauptverdacht — Chrome bringt +mehrere Tonprozesse mit, also legt der Mixer mehrere Geräte an. **Der Verdacht ist +widerlegt:** keines verhungert. + +## Der Fund: der Mixer greift sich selbst ab + +Sobald der Mixer ein Programm bearbeitet, gibt **er** dessen Ton aus. Damit steht er +selbst in `kAudioHardwarePropertyProcessObjectList` — und in seiner eigenen +Programmliste. Eine Zeile wird geregelt, sobald man ihren Regler anfasst. Ein +einziger Klick auf Onyx' eigene Zeile genügt also. + +Was dann passiert, ist gemessen: + +| Zustand | Spitze der Onyx-Ausgabe | +|---|---| +| Onyx bearbeitet nur den Testton | 0,388 | +| Onyx bearbeitet zusätzlich sich selbst | **2,833** | + +Der Pegel schaukelt sich auf, weil Onyx seine eigene Ausgabe wieder abgreift und +erneut ausgibt. Entscheidend ist aber die andere Hälfte: der Selbst-Tap läuft mit +`.mutedWhenTapped` und schaltet damit **Onyx' gesamte Ausgabe** stumm — also die +Wiedergabe *aller* bearbeiteten Programme. Ergebnis: völlige Stille, während die +Pegelanzeigen weiter ausschlagen, weil die Taps ja Signal bekommen. + +Und der Zustand steht in den Einstellungen: er überlebt jeden Neustart. + +**Abhilfe** (`ProcessVisibility.isExcluded`): Onyx erscheint nicht in der eigenen +Liste, lässt sich nicht auswählen, und ein gespeicherter Eintrag wird beim Laden +entfernt und zurückgeschrieben. + +## Nebenbefund + +Ein Tap sieht das Signal **vor** dem Stummschalten. Ein globaler Tap zeigt deshalb +weiterhin den vollen Pegel eines bereits stummgeschalteten Programms — als Nachweis, +ob das Stummschalten greift, taugt er nicht.