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>
294 lines
12 KiB
Swift
294 lines
12 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] = []
|
|
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 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"
|
|
static let muted = "onyx.audio.muted"
|
|
static let managed = "onyx.audio.managed"
|
|
}
|
|
|
|
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) ?? [])
|
|
}
|
|
|
|
// 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
|
|
for tap in taps.values { tap.stop() }
|
|
taps.removeAll()
|
|
lastError = nil
|
|
log.notice("Mixer ausgeschaltet — Tonweg unverändert")
|
|
}
|
|
|
|
// MARK: - Programme
|
|
|
|
private func refresh() {
|
|
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()
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
let wanted = Set(processes.filter { managed.contains($0.bundleID) }.map(\.bundleID))
|
|
|
|
// Beendete oder abgewählte Programme freigeben — sonst bliebe der Tap
|
|
// stehen und schaltete ein Programm stumm, das gar nicht mehr gemeint ist.
|
|
for (bundleID, tap) in taps where !wanted.contains(bundleID) {
|
|
tap.stop()
|
|
taps[bundleID] = nil
|
|
}
|
|
|
|
for process in processes where wanted.contains(process.bundleID) {
|
|
if let existing = taps[process.bundleID] {
|
|
existing.setGain(store.effectiveGain(for: process.bundleID))
|
|
continue
|
|
}
|
|
let tap = ProcessTap(process: process)
|
|
tap.setGain(store.effectiveGain(for: process.bundleID))
|
|
do {
|
|
try tap.start(outputDeviceUID: outputUID)
|
|
taps[process.bundleID] = tap
|
|
lastError = nil
|
|
} catch {
|
|
// Bei einem Fehlschlag nicht in einem halben Zustand
|
|
// verharren: das Programm bleibt sonst stumm zurück.
|
|
tap.stop()
|
|
managed.remove(process.bundleID)
|
|
persist()
|
|
lastError = error.localizedDescription
|
|
log.error("Tap für \(process.name, privacy: .public) fehlgeschlagen: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Baut Taps neu auf, die nur noch Stille liefern.
|
|
private func checkForStalledTaps() {
|
|
for (bundleID, tap) in taps {
|
|
guard tap.isRunning,
|
|
Date().timeIntervalSince(tap.lastAudioAt) > 30,
|
|
processes.first(where: { $0.bundleID == bundleID })?.isPlaying == true
|
|
else { continue }
|
|
|
|
log.error("Tap für \(bundleID, privacy: .public) liefert nur Stille — Neuaufbau")
|
|
tap.stop()
|
|
taps[bundleID] = 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) }
|
|
|
|
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
|
|
}
|
|
}
|