Files
onyx/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift
Scarriffle cfbbf91068 Mixer kann beim Start von Onyx mitlaufen
Ein Häkchen im Mixer selbst. Standardmäßig aus: der Mixer greift in den
Tonweg der geregelten Programme ein, und das soll niemandem ungefragt
passieren — beim ersten Start soll nichts umgeleitet werden, was der Nutzer
nicht angeschaltet hat.

Das Häkchen steht in beiden Zuständen: eingeschaltet, um es wieder
abzuwählen, ausgeschaltet, um es gleich mit zu setzen. Sonst müsste man den
Mixer erst einschalten, um sagen zu können, dass er von selbst einschalten
soll.

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

385 lines
16 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)
}
}
private enum Keys {
static let volumes = "onyx.audio.volumes"
static let muted = "onyx.audio.muted"
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) ?? []
store = VolumeStore(volumes: volumes, muted: muted)
managed = Set(defaults.stringArray(forKey: Keys.managed) ?? [])
startsAutomatically = defaults.bool(forKey: Keys.autostart)
}
/// 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) }
/// 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) {
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 setVolume(_ volume: Double, for bundleID: String) {
store.set(volume, for: bundleID)
taps[bundleID]?.setGain(store.effectiveGain(for: bundleID))
persist()
}
public func setMuted(_ isMuted: Bool, for bundleID: String) {
store.setMuted(isMuted, for: bundleID)
taps[bundleID]?.setGain(store.effectiveGain(for: bundleID))
persist()
}
private func persist() {
defaults.set(store.snapshot, forKey: Keys.volumes)
defaults.set(store.mutedIdentifiers, forKey: Keys.muted)
defaults.set(Array(managed), forKey: Keys.managed)
}
// MARK: - Core Audio
@MainActor
static func currentProcesses() -> [AudioProcess] {
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
// 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 }
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
}
}