Menüleiste springt nicht mehr, Mixer fasst Hilfsprozesse zusammen
Die Breite folgte der Stellenzahl des aktuellen Werts. Aus „14 KB/s" wird „431 KB/s", und mit der zusätzlichen Stelle wanderte die halbe Leiste. Jetzt wieder feste Breiten, bemessen am breitesten Wert, der vorkommen kann. Der frühere Fehler war nicht die feste Breite, sondern der falsche Bezugswert: gezeichnet wird „63°", bemessen wurde „100 °C" — fünfzehn Punkte für zwei Zeichen, die nie erscheinen. Nachgemessen: zwei Aufnahmen im Abstand von sechs Sekunden mit geänderten Werten ergeben identische Breiten. Der Mixer fasst Hilfsprozesse mit ihrem Programm zusammen. „Google Chrome" und „Google Chrome Helper" standen als zwei Zeilen nebeneinander, mit getrennten Reglern für dasselbe Programm — zieht man den einen, hört man den anderen. Chrome hat je Tab einen Helfer; fünfzehn Regler für einen Browser sind keine Auskunft. Abgegriffen wird weiterhin je Prozess, geregelt aber je Programm. Der Schlüssel der Taps muss deshalb den Prozess mitführen: mehrere Helfer teilen sich eine Kennung und überschrieben sich sonst gegenseitig. Der Pegel einer Zeile ist der lauteste ihrer Prozesse. Die Zeilen sind einzeilig statt zweizeilig: Symbol, Stummschalter, Regler, Wert. Der Name ist weg — bei einem erkennbaren Programmsymbol ist er redundant, und die gewonnene Zeile macht die Liste halb so hoch. Dafür ist das Symbol größer, und wer den Namen braucht, hält den Zeiger darauf. Popover 420 statt 330 Punkte breit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,9 @@ private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Au
|
||||
public final class AudioMixer {
|
||||
|
||||
public private(set) var processes: [AudioProcess] = []
|
||||
/// Die Programme, wie sie in der Oberfläche stehen — Hilfsprozesse mit
|
||||
/// ihrem Programm zusammengefasst.
|
||||
public private(set) var groups: [AudioProcessGroup] = []
|
||||
public private(set) var isEnabled = false
|
||||
public private(set) var lastError: String?
|
||||
|
||||
@@ -91,18 +94,22 @@ public final class AudioMixer {
|
||||
private func refresh() {
|
||||
let all = Self.currentProcesses()
|
||||
let now = Date()
|
||||
for process in all where process.isPlaying { lastHeard[process.bundleID] = now }
|
||||
|
||||
// Core Audio führt Dutzende Systemdienste, die theoretisch Ton ausgeben
|
||||
// könnten. In einem Mischpult sind sie nur Rauschen — gezeigt wird, was
|
||||
// klingt, kürzlich klang oder geregelt wird.
|
||||
processes = all.filter { process in
|
||||
// Erst zusammenfassen, dann filtern: sonst verschwindet der
|
||||
// Hauptprozess von Chrome, weil nur sein Helfer Ton ausgibt.
|
||||
let candidates = AudioProcessGroup.group(all)
|
||||
for group in candidates where group.isPlaying { lastHeard[group.bundleID] = now }
|
||||
|
||||
groups = candidates.filter { group in
|
||||
ProcessVisibility.shouldShow(
|
||||
isPlaying: process.isPlaying,
|
||||
secondsSinceHeard: lastHeard[process.bundleID]
|
||||
isPlaying: group.isPlaying,
|
||||
secondsSinceHeard: lastHeard[group.bundleID]
|
||||
.map { now.timeIntervalSince($0) },
|
||||
isManaged: managed.contains(process.bundleID))
|
||||
isManaged: managed.contains(group.bundleID))
|
||||
}
|
||||
processes = groups.flatMap(\.processes)
|
||||
syncTaps()
|
||||
startLevelTimerIfNeeded()
|
||||
}
|
||||
@@ -116,49 +123,65 @@ public final class AudioMixer {
|
||||
return
|
||||
}
|
||||
|
||||
let wanted = Set(processes.filter { managed.contains($0.bundleID) }.map(\.bundleID))
|
||||
// Abgegriffen wird je **Prozess**, geregelt je **Programm**: Chromes
|
||||
// Ton kommt aus mehreren Helfern, die alle denselben Regler bekommen.
|
||||
// Der Schlüssel muss deshalb den Prozess mitführen — mehrere Helfer
|
||||
// teilen sich eine Kennung und überschrieben sonst einander.
|
||||
var wanted: [String: (process: AudioProcess, group: AudioProcessGroup)] = [:]
|
||||
for group in groups where managed.contains(group.bundleID) {
|
||||
for process in group.processes {
|
||||
wanted[Self.tapKey(process)] = (process, group)
|
||||
}
|
||||
}
|
||||
|
||||
// Beendete oder abgewählte Programme freigeben — sonst bliebe der Tap
|
||||
// stehen und schaltete ein Programm stumm, das gar nicht mehr gemeint ist.
|
||||
for (bundleID, tap) in taps where !wanted.contains(bundleID) {
|
||||
for (key, tap) in taps where wanted[key] == nil {
|
||||
tap.stop()
|
||||
taps[bundleID] = nil
|
||||
taps[key] = nil
|
||||
}
|
||||
|
||||
for process in processes where wanted.contains(process.bundleID) {
|
||||
if let existing = taps[process.bundleID] {
|
||||
existing.setGain(store.effectiveGain(for: process.bundleID))
|
||||
for (key, entry) in wanted {
|
||||
let gain = store.effectiveGain(for: entry.group.bundleID)
|
||||
if let existing = taps[key] {
|
||||
existing.setGain(gain)
|
||||
continue
|
||||
}
|
||||
let tap = ProcessTap(process: process)
|
||||
tap.setGain(store.effectiveGain(for: process.bundleID))
|
||||
let tap = ProcessTap(process: entry.process)
|
||||
tap.setGain(gain)
|
||||
do {
|
||||
try tap.start(outputDeviceUID: outputUID)
|
||||
taps[process.bundleID] = tap
|
||||
taps[key] = tap
|
||||
lastError = nil
|
||||
} catch {
|
||||
// Bei einem Fehlschlag nicht in einem halben Zustand
|
||||
// verharren: das Programm bleibt sonst stumm zurück.
|
||||
tap.stop()
|
||||
managed.remove(process.bundleID)
|
||||
managed.remove(entry.group.bundleID)
|
||||
persist()
|
||||
lastError = error.localizedDescription
|
||||
log.error("Tap für \(process.name, privacy: .public) fehlgeschlagen: \(error.localizedDescription, privacy: .public)")
|
||||
log.error("Tap für \(entry.process.name, privacy: .public) fehlgeschlagen: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Tap je Prozess, nicht je Programm — mehrere Helfer eines Programms
|
||||
/// tragen dieselbe Kennung und überschrieben sich sonst gegenseitig.
|
||||
private static func tapKey(_ process: AudioProcess) -> String {
|
||||
"\(process.bundleID)#\(process.pid)"
|
||||
}
|
||||
|
||||
/// Baut Taps neu auf, die nur noch Stille liefern.
|
||||
private func checkForStalledTaps() {
|
||||
for (bundleID, tap) in taps {
|
||||
for (key, tap) in taps {
|
||||
guard tap.isRunning,
|
||||
tap.secondsSinceAudio > 30,
|
||||
processes.first(where: { $0.bundleID == bundleID })?.isPlaying == true
|
||||
tap.process.isPlaying
|
||||
else { continue }
|
||||
|
||||
log.error("Tap für \(bundleID, privacy: .public) liefert nur Stille — Neuaufbau")
|
||||
log.error("Tap für \(key, privacy: .public) liefert nur Stille — Neuaufbau")
|
||||
tap.stop()
|
||||
taps[bundleID] = nil
|
||||
taps[key] = nil
|
||||
}
|
||||
syncTaps()
|
||||
}
|
||||
@@ -202,13 +225,20 @@ public final class AudioMixer {
|
||||
levelTimer = nil
|
||||
return
|
||||
}
|
||||
// Der Pegel gilt je **Programm**: Chromes Ton verteilt sich auf mehrere
|
||||
// Helfer, und die Zeile zeigt den lautesten davon.
|
||||
var peaks: [String: Double] = [:]
|
||||
for (_, tap) in taps {
|
||||
let app = ProcessNaming.parentBundleID(for: tap.process.bundleID)
|
||||
peaks[app] = max(peaks[app] ?? 0, Double(tap.takePeak()))
|
||||
}
|
||||
|
||||
var next: [String: Double] = [:]
|
||||
for (bundleID, tap) in taps {
|
||||
let peak = Double(tap.takePeak())
|
||||
let previous = levels[bundleID] ?? 0
|
||||
for (app, peak) in peaks {
|
||||
let previous = levels[app] ?? 0
|
||||
// Schnell hoch, langsam runter. Ein Pegel, der jedem Puffer folgt,
|
||||
// flackert; einer, der nur fällt, verpasst die Spitzen.
|
||||
next[bundleID] = peak > previous ? peak : previous * 0.72
|
||||
next[app] = peak > previous ? peak : previous * 0.72
|
||||
}
|
||||
levels = next
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user