Files
onyx/Packages/OnyxKit/Sources/AudioProvider/ProcessNaming.swift
Scarriffle d001e439a6 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>
2026-08-13 17:57:15 +02:00

132 lines
5.8 KiB
Swift

import Foundation
import AppKit
/// Macht aus Core-Audio-Prozessen etwas, das man wiedererkennt.
///
/// Core Audio meldet **Prozesse**, nicht Programme. Was dort ankommt, heißt
/// `com.google.Chrome.helper` oder `com.apple.WebKit.GPU`. Technisch richtig,
/// im Mischpult unbrauchbar: wer Spotify leiser" sucht, sucht nach Spotify".
public enum ProcessNaming {
/// Endungen, mit denen Programme ihre Hilfsprozesse benennen.
///
/// Chromes Ton kommt aus einem solchen Prozess abgegriffen wird also
/// zurecht der Helfer, angezeigt aber das Programm dahinter.
private static let helperSuffixes = [
".helper", ".Helper", ".ServiceExtension", ".Renderer", ".GPU",
".plugin", ".xpc", ".Service",
]
/// Die Kennung des Programms, zu dem ein Prozess gehört.
public static func parentBundleID(for bundleID: String) -> String {
var result = bundleID
// Mehrfach kürzen: `com.google.Chrome.helper.Renderer` hat zwei.
var changed = true
while changed {
changed = false
for suffix in helperSuffixes where result.hasSuffix(suffix) {
result.removeLast(suffix.count)
changed = true
}
}
return result.isEmpty ? bundleID : result
}
/// Der anzuzeigende Name, wenn das Programm selbst nicht gefragt werden kann.
public static func fallbackName(for bundleID: String) -> String {
guard !bundleID.isEmpty else { return "Unbekannt" }
let parent = parentBundleID(for: bundleID)
// Der letzte Bestandteil ist bei umgekehrter Domainschreibweise der
// Programmname com.spotify.client" ist die Ausnahme, die auf einen
// generischen Begriff endet.
let components = parent.split(separator: ".")
// Ein einzelnes Wort ist keine umgekehrte Domain, sondern ein
// Prozessname wie `systemsoundserverd`. Den groß zu schreiben macht ihn
// nicht lesbarer, nur falsch.
guard components.count > 1 else { return parent }
guard let last = components.last else { return bundleID }
let generic = ["client", "app", "mac", "macos", "desktop"]
let chosen = generic.contains(last.lowercased()) && components.count > 1
? components[components.count - 2]
: last
return chosen.prefix(1).uppercased() + chosen.dropFirst()
}
/// Der beste verfügbare Name: erst das laufende Programm fragen, dann den
/// übergeordneten Prozess, dann die Kennung zerlegen.
@MainActor
public static func displayName(bundleID: String, pid: pid_t) -> String {
if let name = NSRunningApplication(processIdentifier: pid)?.localizedName {
return name
}
let parent = parentBundleID(for: bundleID)
if parent != bundleID,
let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: parent) {
return FileManager.default.displayName(atPath: url.path)
.replacingOccurrences(of: ".app", with: "")
}
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) {
return FileManager.default.displayName(atPath: url.path)
.replacingOccurrences(of: ".app", with: "")
}
return fallbackName(for: bundleID)
}
/// Das Symbol des Programms auch für Hilfsprozesse.
@MainActor
public static func icon(bundleID: String, pid: pid_t) -> NSImage? {
if let icon = NSRunningApplication(processIdentifier: pid)?.icon { return icon }
let parent = parentBundleID(for: bundleID)
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: parent) {
return NSWorkspace.shared.icon(forFile: url.path)
}
return nil
}
}
/// Wer im Mischpult auftaucht.
public enum ProcessVisibility {
/// Wie lange ein verstummtes Programm noch sichtbar bleibt.
///
/// Ohne diese Nachlaufzeit verschwindet Spotify beim Pausieren aus der
/// Liste und man kann die Lautstärke genau dann nicht mehr einstellen,
/// wenn man es will.
public static let gracePeriod: TimeInterval = 120
/// - Parameters:
/// - isPlaying: gibt der Prozess gerade Ton aus?
/// - secondsSinceHeard: wie lange ist das letzte Mal her? `nil` = noch nie.
/// - isManaged: wird seine Lautstärke von Onyx geregelt?
public static func shouldShow(isPlaying: Bool,
secondsSinceHeard: TimeInterval?,
isManaged: Bool) -> Bool {
// Geregelte Programme immer: wer eine Lautstärke eingestellt hat, muss
// sie auch zurücknehmen können.
if isManaged || isPlaying { return true }
guard let seconds = secondsSinceHeard else { return false }
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")
}
}