Files
onyx/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift
Scarriffle 7a3d2c1c39 Pegel auch für Ausgabe und Mikrofon
Die Programmzeilen hatten ihren mitlaufenden Ausschlag, die beiden Systemregler
nicht. Jetzt beide, in derselben Form: der Pegel läuft im Regler mit, nicht
daneben.

Der Ausgangspegel kommt über einen globalen Tap — denselben Weg wie der Mixer,
nur ohne Stummschalten und ohne Ausgabe. Ausgenommen werden die Programme, die
der Mixer bereits abgreift: ein Tap sieht das Signal vor dem Stummschalten,
sie zählten sonst doppelt — einmal als Original, das niemand hört, und einmal
als das, was Onyx daraus macht. Ändert sich diese Liste, wird der Tap neu
aufgebaut.

Der Mikrofonpegel geht nur mit echter Aufnahme. Es gibt keine Eigenschaft am
Gerät, die den Ausschlag verrät — einen Pegel bekommt nur, wer das Signal
liest, und genau das meldet macOS mit dem orangen Punkt. Das ist richtig so,
also läuft die Messung nur, solange die Ansicht offen ist, und hört beim
Schließen sofort auf. Ein Zähler statt eines Schalters, weil Panel und Popover
gleichzeitig offen sein können.

Geräte, die keine Fließkommawerte liefern, bleiben ohne Pegel: als Fließkomma
gelesene Ganzzahlen ergeben keinen falschen Ausschlag, sondern Unsinn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:17:40 +02:00

437 lines
18 KiB
Swift

import Foundation
import AppKit
import CoreAudio
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio")
/// Der Per-Programm-Lautstärkemixer.
///
/// **Er ist standardmäßig aus.** Eingeschaltet greift er in den Tonweg jedes
/// gewählten Programms ein geht dabei etwas schief, ist das Programm still.
/// Ein Werkzeug, das ungefragt zwischen Musik und Lautsprecher tritt, hat man
/// sich nicht ausgesucht.
@MainActor
@Observable
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?
/// Programme, die vom Mixer bearbeitet werden. Alle anderen laufen
/// unverändert am Mixer vorbei.
public private(set) var managed: Set<String> = []
private var store = VolumeStore()
private var taps: [String: ProcessTap] = [:]
private var refreshTimer: Timer?
private var watchdogTimer: Timer?
private var levelTimer: Timer?
/// Der letzte Ausschlag je Programm, 01.
public private(set) var levels: [String: Double] = [:]
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] = [:]
/// Ob der Mixer beim Programmstart von selbst anläuft.
///
/// Standardmäßig aus: der Mixer greift in den Tonweg ein, und das soll
/// niemandem ungefragt passieren. Wer ihn täglich benutzt, spart sich mit
/// dem Häkchen den Klick.
public var startsAutomatically: Bool {
didSet {
guard startsAutomatically != oldValue else { return }
defaults.set(startsAutomatically, forKey: Keys.autostart)
}
}
/// 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"
static let overdrive = "onyx.audio.overdrive"
static let managed = "onyx.audio.managed"
static let autostart = "onyx.audio.autostart"
}
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
let volumes = defaults.dictionary(forKey: Keys.volumes) as? [String: Double] ?? [:]
let muted = defaults.stringArray(forKey: Keys.muted) ?? []
let overdrive = defaults.stringArray(forKey: Keys.overdrive) ?? []
store = VolumeStore(volumes: volumes, muted: muted, overdrive: overdrive)
// 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.
public func enableIfWanted() {
guard startsAutomatically else { return }
enable()
}
// MARK: - Ein und aus
public func enable() {
guard !isEnabled else { return }
isEnabled = true
refresh()
refreshTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.refresh() }
}
// Der bekannte Fehlerfall aus Spike C: Taps liefern nach einiger Zeit
// nur noch Nullen. Von außen sieht das aus, als wäre das Programm
// stumm und genau das ist es dann auch, weil der Tap das Original
// stummschaltet.
watchdogTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.checkForStalledTaps() }
}
observeDeviceChanges()
log.notice("Mixer eingeschaltet")
}
/// Notausschalter. Stellt den unveränderten Systemzustand wieder her.
///
/// Muss immer erreichbar sein und darf nie fehlschlagen: wenn der Ton weg
/// ist, ist das der Knopf, den man sucht.
public func disable() {
isEnabled = false
refreshTimer?.invalidate(); refreshTimer = nil
watchdogTimer?.invalidate(); watchdogTimer = nil
levelTimer?.invalidate(); levelTimer = nil
for tap in taps.values { tap.stop() }
taps.removeAll()
levels = [:]
lastError = nil
log.notice("Mixer ausgeschaltet — Tonweg unverändert")
}
// MARK: - Programme
private func refresh() {
let all = Self.currentProcesses()
let now = Date()
// 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.
// 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: group.isPlaying,
secondsSinceHeard: lastHeard[group.bundleID]
.map { now.timeIntervalSince($0) },
isManaged: managed.contains(group.bundleID))
}
processes = groups.flatMap(\.processes)
syncTaps()
startLevelTimerIfNeeded()
}
/// Legt Taps an und ab, sodass sie zu `managed` und den laufenden
/// Programmen passen.
private func syncTaps() {
guard isEnabled else { return }
guard let outputUID = Self.defaultOutputUID() else {
lastError = AudioMixerError.noOutputDevice.errorDescription
return
}
// 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 (key, tap) in taps where wanted[key] == nil {
tap.stop()
taps[key] = nil
}
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: entry.process)
tap.setGain(gain)
do {
try tap.start(outputDeviceUID: outputUID)
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(entry.group.bundleID)
persist()
lastError = error.localizedDescription
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 (key, tap) in taps {
guard tap.isRunning,
tap.secondsSinceAudio > 30,
tap.process.isPlaying
else { continue }
log.error("Tap für \(key, privacy: .public) liefert nur Stille — Neuaufbau")
tap.stop()
taps[key] = nil
}
syncTaps()
}
/// Wechselt das Ausgabegerät, müssen alle Taps neu aufgebaut werden: sie
/// hängen am alten Gerät und gäben ins Leere aus.
private func observeDeviceChanges() {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
AudioObjectAddPropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject), &address, .main) { [weak self] _, _ in
MainActor.assumeIsolated {
guard let self, self.isEnabled else { return }
log.notice("Ausgabegerät gewechselt — Taps werden neu aufgebaut")
for tap in self.taps.values { tap.stop() }
self.taps.removeAll()
self.syncTaps()
}
}
}
// MARK: - Steuerung
public func isManaged(_ bundleID: String) -> Bool { managed.contains(bundleID) }
/// Die Prozesse, deren Originalton gerade stummgeschaltet ist.
///
/// Der Ausgangspegel muss sie auslassen: ein Tap sieht das Signal **vor**
/// dem Stummschalten, sie zählten sonst doppelt einmal als Original, das
/// niemand hört, und einmal als das, was Onyx daraus macht.
public var tappedProcessIDs: [AudioObjectID] {
taps.values.map(\.process.objectID)
}
/// Der Ausschlag eines Programms, 01 für die Pegelanzeige.
public func level(for bundleID: String) -> Double { levels[bundleID] ?? 0 }
/// Holt die Ausschläge ab.
///
/// Läuft nur, solange überhaupt Taps bestehen: ohne abgegriffenes Programm
/// gibt es nichts zu messen, und ein Zeitgeber, der 15-mal pro Sekunde
/// nichts tut, gehört nicht in eine App, die im Hintergrund ruhig sein soll.
private func sampleLevels() {
guard !taps.isEmpty else {
if !levels.isEmpty { levels = [:] }
levelTimer?.invalidate()
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 (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[app] = peak > previous ? peak : previous * 0.72
}
levels = next
}
private func startLevelTimerIfNeeded() {
guard levelTimer == nil, !taps.isEmpty else { return }
let timer = Timer(timeInterval: 1.0 / 15, repeats: true) { [weak self] _ in
Task { @MainActor in self?.sampleLevels() }
}
RunLoop.main.add(timer, forMode: .common)
levelTimer = timer
}
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()
}
public func volume(for bundleID: String) -> Double { store.volume(for: bundleID) }
public func isMuted(_ bundleID: String) -> Bool { store.isMuted(for: bundleID) }
public func isOverdriven(_ bundleID: String) -> Bool { store.isOverdriven(bundleID) }
/// Gibt den Bereich über 100 % frei je Programm, nicht für alle.
///
/// Ein Browser braucht ihn für eine leise Konferenz, die Musik daneben
/// nicht. Und ein Regler, der überall bis 400 % geht, führt genau dorthin,
/// wo man ihn nicht haben will.
public func setOverdrive(_ isOn: Bool, for bundleID: String) {
store.setOverdrive(isOn, for: bundleID)
applyGain(for: bundleID)
persist()
}
public func setVolume(_ volume: Double, for bundleID: String) {
store.set(volume, for: bundleID)
applyGain(for: bundleID)
persist()
}
public func setMuted(_ isMuted: Bool, for bundleID: String) {
store.setMuted(isMuted, 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)
defaults.set(store.overdrivenIdentifiers, forKey: Keys.overdrive)
defaults.set(Array(managed), forKey: Keys.managed)
}
// MARK: - Core Audio
@MainActor
static func currentProcesses() -> [AudioProcess] {
let ownBundleID = Self.ownBundleID
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyProcessObjectList,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject),
&address, 0, nil, &size) == noErr, size > 0
else { return [] }
let count = Int(size) / MemoryLayout<AudioObjectID>.stride
let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size),
alignment: MemoryLayout<AudioObjectID>.alignment)
defer { raw.deallocate() }
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),
&address, 0, nil, &size, raw) == noErr else { return [] }
let objects = Array(UnsafeBufferPointer(
start: raw.assumingMemoryBound(to: AudioObjectID.self), count: count))
return objects.compactMap { object -> AudioProcess? in
guard let bundleID = string(object, kAudioProcessPropertyBundleID),
!bundleID.isEmpty else { return nil }
let pid = value(object, kAudioProcessPropertyPID, pid_t(0)) ?? -1
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
return AudioProcess(objectID: object, pid: pid, bundleID: bundleID,
name: name, isPlaying: playing)
}
// Was gerade klingt, gehört nach oben.
.sorted { ($0.isPlaying ? 0 : 1, $0.name.lowercased())
< ($1.isPlaying ? 0 : 1, $1.name.lowercased()) }
}
static func defaultOutputUID() -> String? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
guard let device = value(AudioObjectID(kAudioObjectSystemObject), nil,
AudioObjectID(kAudioObjectUnknown), address: &address)
else { return nil }
return string(device, kAudioDevicePropertyDeviceUID)
}
private static func value<T>(_ object: AudioObjectID, _ selector: AudioObjectPropertySelector?,
_ initial: T,
address custom: UnsafeMutablePointer<AudioObjectPropertyAddress>? = nil)
-> T? {
var address = custom?.pointee ?? AudioObjectPropertyAddress(
mSelector: selector ?? 0, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var result = initial
var size = UInt32(MemoryLayout<T>.size)
guard AudioObjectGetPropertyData(object, &address, 0, nil, &size, &result) == noErr
else { return nil }
return result
}
private static func string(_ object: AudioObjectID,
_ selector: AudioObjectPropertySelector) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var result: CFString?
var size = UInt32(MemoryLayout<CFString?>.size)
let status = withUnsafeMutablePointer(to: &result) {
AudioObjectGetPropertyData(object, &address, 0, nil, &size, $0)
}
guard status == noErr, let result else { return nil }
return result as String
}
}