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 = [] 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) } } /// 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, 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) { 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.stride let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: MemoryLayout.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(_ object: AudioObjectID, _ selector: AudioObjectPropertySelector?, _ initial: T, address custom: UnsafeMutablePointer? = nil) -> T? { var address = custom?.pointee ?? AudioObjectPropertyAddress( mSelector: selector ?? 0, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var result = initial var size = UInt32(MemoryLayout.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.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 } }