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:
Scarriffle
2026-08-13 17:57:15 +02:00
parent ed5bd01482
commit d001e439a6
7 changed files with 636 additions and 7 deletions

View File

@@ -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

View File

@@ -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")
}
}