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>
385 lines
16 KiB
Swift
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, 0…1.
|
|
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, 0…1 — 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
|
|
}
|
|
}
|