Audiomixer zeigt Programme statt Prozesse

Core Audio meldet Prozesse, nicht Programme. Angezeigt wurden deshalb
Kennungen wie „com.apple.WebKit.GPU" und Dutzende Systemdienste, die
theoretisch Ton ausgeben könnten, es aber nie tun.

ProcessNaming fragt erst das laufende Programm, kürzt sonst bekannte
Hilfsprozess-Endungen weg und schlägt das übergeordnete Programm nach —
Chromes Ton kommt aus einem Helfer, angezeigt gehört Chrome. Symbole
gehen denselben Weg, sonst steht neben dem Namen ein Platzhalter.

ProcessVisibility zeigt, was klingt, kürzlich klang oder geregelt wird.
Die Nachlaufzeit von zwei Minuten ist der eigentliche Punkt: ohne sie
verschwindet Spotify beim Pausieren aus der Liste, und man kann die
Lautstärke genau dann nicht mehr einstellen, wenn man es will. Geregelte
Programme bleiben immer — wer etwas leiser gedreht hat, muss es auch
zurückdrehen können.

Die Prozentzahl wandert vom Namen zum Regler. Oben war sie genau die
Breite, die den Namen abgeschnitten hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-11 09:54:20 +02:00
parent c71ddb7ce3
commit 28b1b4270e
4 changed files with 265 additions and 24 deletions

View File

@@ -28,6 +28,9 @@ public final class AudioMixer {
private var refreshTimer: Timer?
private var watchdogTimer: Timer?
private let defaults: UserDefaults
/// Wann ein Prozess zuletzt Ton ausgegeben hat. Ohne dieses Gedächtnis
/// verschwindet ein pausiertes Programm sofort aus der Liste.
private var lastHeard: [String: Date] = [:]
private enum Keys {
static let volumes = "onyx.audio.volumes"
@@ -81,7 +84,20 @@ public final class AudioMixer {
// MARK: - Programme
private func refresh() {
processes = Self.currentProcesses()
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
ProcessVisibility.shouldShow(
isPlaying: process.isPlaying,
secondsSinceHeard: lastHeard[process.bundleID]
.map { now.timeIntervalSince($0) },
isManaged: managed.contains(process.bundleID))
}
syncTaps()
}
@@ -194,6 +210,7 @@ public final class AudioMixer {
// MARK: - Core Audio
@MainActor
static func currentProcesses() -> [AudioProcess] {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyProcessObjectList,
@@ -225,7 +242,7 @@ public final class AudioMixer {
guard !bundleID.hasPrefix("com.apple.audio"),
!bundleID.hasPrefix("com.apple.core") else { return nil }
let name = NSRunningApplication(processIdentifier: pid)?.localizedName ?? bundleID
let name = ProcessNaming.displayName(bundleID: bundleID, pid: pid)
let playing = (value(object, kAudioProcessPropertyIsRunningOutput, UInt32(0)) ?? 0) != 0
return AudioProcess(objectID: object, pid: pid, bundleID: bundleID,
name: name, isPlaying: playing)

View File

@@ -93,6 +93,10 @@ private struct ProcessRow: View {
.foregroundStyle(process.isPlaying ? Onyx.Color.textPrimary
: Onyx.Color.textSecondary)
.lineLimit(1)
// In der Mitte kürzen statt am Ende: bei zwei ähnlichen
// Namen ist das Ende oft das Unterscheidende.
.truncationMode(.middle)
.layoutPriority(1)
if process.isPlaying {
Circle().fill(Onyx.Color.positive).frame(width: 5, height: 5)
@@ -100,11 +104,6 @@ private struct ProcessRow: View {
Spacer(minLength: 0)
if managed {
Text("\(Int(mixer.volume(for: process.bundleID) * 100)) %")
.font(Onyx.Font.metricSmall)
.foregroundStyle(mixer.volume(for: process.bundleID) > 1
? Onyx.Color.warning : Onyx.Color.textTertiary)
Button {
mixer.setMuted(!mixer.isMuted(process.bundleID), for: process.bundleID)
} label: {
@@ -128,6 +127,10 @@ private struct ProcessRow: View {
}
if managed {
// Die Prozentzahl steht beim Regler, nicht beim Namen: oben ist
// sie die Breite, die den Namen abschneidet, hier gehört sie
// ohnehin hin.
HStack(spacing: 6) {
Slider(value: Binding(
get: { mixer.volume(for: process.bundleID) },
set: { mixer.setVolume($0, for: process.bundleID) }),
@@ -144,14 +147,23 @@ private struct ProcessRow: View {
.offset(x: geometry.size.width / Gain.maximumVolume, y: 4)
}
}
Text("\(Int(mixer.volume(for: process.bundleID) * 100)) %")
.font(Onyx.Font.metricSmall)
.foregroundStyle(mixer.volume(for: process.bundleID) > 1
? Onyx.Color.warning : Onyx.Color.textTertiary)
// Feste Breite, sonst wandert der Regler beim Ziehen.
.frame(width: 34, alignment: .trailing)
}
}
}
}
@ViewBuilder
private var icon: some View {
if let app = NSRunningApplication(processIdentifier: process.pid),
let image = app.icon {
// Auch Hilfsprozesse bekommen das Symbol ihres Programms sonst steht
// neben Google Chrome" ein leerer Platzhalter.
if let image = ProcessNaming.icon(bundleID: process.bundleID, pid: process.pid) {
Image(nsImage: image).resizable().frame(width: 14, height: 14)
} else {
Image(systemName: "app.dashed")

View File

@@ -0,0 +1,112 @@
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
}
}

View File

@@ -0,0 +1,100 @@
import Testing
@testable import AudioProvider
// Core Audio meldet Prozesse, nicht Programme. Was dort ankommt, heißt
// `com.google.Chrome.helper` oder `com.apple.WebKit.GPU` Kennungen, die in
// einem Mischpult nichts zu suchen haben. Wer Spotify leiser" sucht, sucht
// nach Spotify".
@Suite("Programmnamen")
struct ProcessNameTests {
@Test("Aus einer Kennung wird ein lesbarer Name")
func plainIdentifier() {
#expect(ProcessNaming.fallbackName(for: "com.spotify.client") == "Spotify")
}
@Test("Hilfsprozesse werden ihrem Programm zugeordnet")
func helpersMapToParent() {
// Chromes Ton kommt aus einem Hilfsprozess. Ihn als
// com.google.Chrome.helper" zu zeigen wäre technisch richtig und
// praktisch nutzlos.
#expect(ProcessNaming.parentBundleID(for: "com.google.Chrome.helper")
== "com.google.Chrome")
#expect(ProcessNaming.parentBundleID(for: "com.google.Chrome.helper.Renderer")
== "com.google.Chrome")
}
@Test("Weitere übliche Endungen von Hilfsprozessen")
func knownHelperSuffixes() {
#expect(ProcessNaming.parentBundleID(for: "net.whatsapp.WhatsApp.ServiceExtension")
== "net.whatsapp.WhatsApp")
#expect(ProcessNaming.parentBundleID(for: "com.example.App.Helper")
== "com.example.App")
}
@Test("Eine gewöhnliche Kennung bleibt unverändert")
func plainIdentifierIsUntouched() {
#expect(ProcessNaming.parentBundleID(for: "com.spotify.client") == "com.spotify.client")
}
@Test("Der Hilfsprozess einer Komponente zeigt die Komponente")
func helperShowsItsComponent() {
// `.GPU` ist eine Hilfsprozess-Endung, also bleibt WebKit übrig. Das
// ist die nützlichere Auskunft: GPU" allein sagt niemandem etwas.
#expect(ProcessNaming.fallbackName(for: "com.apple.WebKit.GPU") == "WebKit")
#expect(ProcessNaming.fallbackName(for: "pro.betterdisplay.BetterDisplay")
== "BetterDisplay")
}
@Test("Eine Kennung ohne Punkte bleibt sie selbst")
func singleComponent() {
#expect(ProcessNaming.fallbackName(for: "systemsoundserverd") == "systemsoundserverd")
}
@Test("Eine leere Kennung ergibt keinen leeren Namen")
func emptyIsHandled() {
#expect(!ProcessNaming.fallbackName(for: "").isEmpty)
}
}
@Suite("Sichtbarkeit im Mischpult")
struct ProcessVisibilityTests {
@Test("Was gerade Ton ausgibt, wird gezeigt")
func playingIsVisible() {
#expect(ProcessVisibility.shouldShow(isPlaying: true, secondsSinceHeard: nil,
isManaged: false))
}
@Test("Was nie Ton ausgegeben hat, wird nicht gezeigt")
func neverHeardIsHidden() {
// Der eigentliche Punkt: Core Audio führt Dutzende Systemdienste, die
// theoretisch Ton ausgeben könnten. In einem Mischpult sind sie nur
// Rauschen.
#expect(!ProcessVisibility.shouldShow(isPlaying: false, secondsSinceHeard: nil,
isManaged: false))
}
@Test("Kurz nach dem Verstummen bleibt es sichtbar")
func recentlyHeardStaysVisible() {
// Sonst verschwindet Spotify beim Pausieren aus der Liste, und man kann
// die Lautstärke genau dann nicht mehr einstellen, wenn man es will.
#expect(ProcessVisibility.shouldShow(isPlaying: false, secondsSinceHeard: 30,
isManaged: false))
}
@Test("Lange danach verschwindet es")
func longSilentDisappears() {
#expect(!ProcessVisibility.shouldShow(isPlaying: false, secondsSinceHeard: 600,
isManaged: false))
}
@Test("Ein geregeltes Programm bleibt immer sichtbar")
func managedIsAlwaysVisible() {
// Wer die Lautstärke von etwas eingestellt hat, muss sie auch wieder
// zurücknehmen können auch wenn gerade nichts läuft.
#expect(ProcessVisibility.shouldShow(isPlaying: false, secondsSinceHeard: 99999,
isManaged: true))
}
}