Files
onyx/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift
Scarriffle a0342205fd Phase 9: Einrichtung, Autostart, Notarisierung — und die Mixer-Optik
Die Einrichtung beim ersten Start erklärt jede der vier Sachen einzeln und
fragt jede einzeln an. Vier Systemdialoge hintereinander wären der übliche
Weg und der falsche: wer nicht weiß wofür, klickt viermal „Nicht erlauben",
und danach fragt macOS nie wieder. Sie kommt beim ersten Start immer, auch
wenn zufällig schon alles erlaubt ist — sie erklärt auch den Helfer und den
Autostart.

Beim Autostart ist `requiresApproval` der Zustand, der weh tut. Wer das
Anmeldeobjekt in den Systemeinstellungen abgeschaltet hat, kann in Onyx auf
den Schalter drücken, so oft er will. Als „aus" angezeigt führt das in eine
Schleife, also steht dort jetzt, wo es weitergeht.

Der Mixer verliert den Schalter je Programm. Zehn Mini-Schalter untereinander
sind das, was den Eindruck macht; am Regler zu ziehen ist ohnehin die
Entscheidung, dieses Programm zu regeln, und der große Ausschalter stellt
alles zurück. Der Regler ist selbst gezeichnet — der Systemregler bringt sein
eigenes Erscheinungsbild mit und kann nicht zeigen, worauf es hier ankommt:
den Pegel und die Grenze bei 100 %.

Der Pegel wird im selben Durchlauf gemessen, in dem verstärkt wird, und über
`Atomic` weitergereicht. Eine gewöhnliche Eigenschaft wäre ein Wettlauf, eine
Sperre im Audiothread der klassische Weg zu Aussetzern. Dabei fällt das
`Date()` aus dem Echtzeitpfad, das dort nie hingehört hat.

Zwei Funde beim Bauen des Release-Wegs, beide hätten erst nach dem Hochladen
zur Ablehnung geführt: der Helfer wurde ohne sicheren Zeitstempel signiert,
und das Adapter-Framework ad-hoc. Beides gilt jetzt nur noch im Debug.
Scripts/release.sh prüft Zertifikat und Notarisierungsprofil vorweg, statt
nach zehn Minuten Bauen zu scheitern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:36:26 +02:00

335 lines
14 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 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] = [:]
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
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()
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()
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
}
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,
tap.secondsSinceAudio > 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) }
/// 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
}
var next: [String: Double] = [:]
for (bundleID, tap) in taps {
let peak = Double(tap.takePeak())
let previous = levels[bundleID] ?? 0
// Schnell hoch, langsam runter. Ein Pegel, der jedem Puffer folgt,
// flackert; einer, der nur fällt, verpasst die Spitzen.
next[bundleID] = 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
}
}