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:
@@ -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 {
|
private enum Keys {
|
||||||
static let volumes = "onyx.audio.volumes"
|
static let volumes = "onyx.audio.volumes"
|
||||||
static let muted = "onyx.audio.muted"
|
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 volumes = defaults.dictionary(forKey: Keys.volumes) as? [String: Double] ?? [:]
|
||||||
let muted = defaults.stringArray(forKey: Keys.muted) ?? []
|
let muted = defaults.stringArray(forKey: Keys.muted) ?? []
|
||||||
store = VolumeStore(volumes: volumes, muted: 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)
|
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.
|
/// 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) {
|
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) }
|
if isManaged { managed.insert(bundleID) } else { managed.remove(bundleID) }
|
||||||
persist()
|
persist()
|
||||||
syncTaps()
|
syncTaps()
|
||||||
@@ -283,16 +298,30 @@ public final class AudioMixer {
|
|||||||
|
|
||||||
public func setVolume(_ volume: Double, for bundleID: String) {
|
public func setVolume(_ volume: Double, for bundleID: String) {
|
||||||
store.set(volume, for: bundleID)
|
store.set(volume, for: bundleID)
|
||||||
taps[bundleID]?.setGain(store.effectiveGain(for: bundleID))
|
applyGain(for: bundleID)
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setMuted(_ isMuted: Bool, for bundleID: String) {
|
public func setMuted(_ isMuted: Bool, for bundleID: String) {
|
||||||
store.setMuted(isMuted, for: bundleID)
|
store.setMuted(isMuted, for: bundleID)
|
||||||
taps[bundleID]?.setGain(store.effectiveGain(for: bundleID))
|
applyGain(for: bundleID)
|
||||||
persist()
|
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() {
|
private func persist() {
|
||||||
defaults.set(store.snapshot, forKey: Keys.volumes)
|
defaults.set(store.snapshot, forKey: Keys.volumes)
|
||||||
defaults.set(store.mutedIdentifiers, forKey: Keys.muted)
|
defaults.set(store.mutedIdentifiers, forKey: Keys.muted)
|
||||||
@@ -303,6 +332,7 @@ public final class AudioMixer {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
static func currentProcesses() -> [AudioProcess] {
|
static func currentProcesses() -> [AudioProcess] {
|
||||||
|
let ownBundleID = Self.ownBundleID
|
||||||
var address = AudioObjectPropertyAddress(
|
var address = AudioObjectPropertyAddress(
|
||||||
mSelector: kAudioHardwarePropertyProcessObjectList,
|
mSelector: kAudioHardwarePropertyProcessObjectList,
|
||||||
mScope: kAudioObjectPropertyScopeGlobal,
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
@@ -328,10 +358,8 @@ public final class AudioMixer {
|
|||||||
!bundleID.isEmpty else { return nil }
|
!bundleID.isEmpty else { return nil }
|
||||||
let pid = value(object, kAudioProcessPropertyPID, pid_t(0)) ?? -1
|
let pid = value(object, kAudioProcessPropertyPID, pid_t(0)) ?? -1
|
||||||
|
|
||||||
// Systemdienste wie coreaudiod oder die Sprachausgabe gehören nicht
|
guard !ProcessVisibility.isExcluded(bundleID: bundleID,
|
||||||
// in eine Liste, in der man „Spotify leiser" sucht.
|
ownBundleID: ownBundleID) else { return nil }
|
||||||
guard !bundleID.hasPrefix("com.apple.audio"),
|
|
||||||
!bundleID.hasPrefix("com.apple.core") else { return nil }
|
|
||||||
|
|
||||||
let name = ProcessNaming.displayName(bundleID: bundleID, pid: pid)
|
let name = ProcessNaming.displayName(bundleID: bundleID, pid: pid)
|
||||||
let playing = (value(object, kAudioProcessPropertyIsRunningOutput, UInt32(0)) ?? 0) != 0
|
let playing = (value(object, kAudioProcessPropertyIsRunningOutput, UInt32(0)) ?? 0) != 0
|
||||||
|
|||||||
@@ -110,3 +110,22 @@ public enum ProcessVisibility {
|
|||||||
return seconds <= gracePeriod
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Spikes/build-mixer-path-probe.sh
Executable file
21
Spikes/build-mixer-path-probe.sh
Executable file
@@ -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" "$@"
|
||||||
286
Spikes/mixer-path-probe.swift
Normal file
286
Spikes/mixer-path-probe.swift
Normal file
@@ -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<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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<AudioStreamBasicDescription>.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<Float>.size
|
||||||
|
let inputSamples = source.assumingMemoryBound(to: Float.self)
|
||||||
|
let outputSamples = destination.assumingMemoryBound(to: Float.self)
|
||||||
|
for sample in 0..<count {
|
||||||
|
let value = inputSamples[sample]
|
||||||
|
// Beim reinen Messen wird **nicht** durchgereicht: sonst hört
|
||||||
|
// der globale Tap die eigene Wiedergabe wieder mit, und der
|
||||||
|
// Pegel schaukelt sich auf, statt den Ist-Zustand zu zeigen.
|
||||||
|
outputSamples[sample] = passthrough ? value : 0
|
||||||
|
let magnitude = abs(value)
|
||||||
|
if magnitude > 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.")
|
||||||
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.")
|
||||||
@@ -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
|
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
|
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.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user