Ein Tap mit mutedWhenTapped schaltet die ursprüngliche Ausgabe eines Programms stumm und liefert das Signal stattdessen bei Onyx ab; ein privates Aggregate-Device aus Tap und echtem Ausgabegerät gibt es nach der Verstärkung wieder aus. Das ist der einzige Weg, den macOS ohne eigenen Treiber anbietet. Die Kennlinie ist hergeleitet, nicht geraten: halbe empfundene Lautstärke sind rund −10 dB, also Faktor 0,316. Aus 0,5^k = 0,316 folgt k ≈ 1,66. Die Skala ist damit symmetrisch — 50 % sind −10 dB, 200 % sind +10 dB. Mein erster Ansatz mit der vierten Potenz ergab bei halbem Regler 6 % statt 32 %; der Test hat das gefangen, bevor es an einen Lautsprecher kam. Über 100 % greift ein weicher Begrenzer. Ohne ihn schneidet die Hardware die Werte hart ab, und das knackt hörbar. Er arbeitet erst ab 0,7, weil eine Begrenzung, die schon bei Zimmerlautstärke einsetzt, matt klingt. Der Mixer ist standardmäßig aus, und auch eingeschaltet greift er nur bei ausdrücklich gewählten Programmen ein. Alles andere läuft unverändert vorbei. Der Notausschalter steht sichtbar in der Ansicht, nicht in einem Menü: wenn der Ton weg ist, ist das der Knopf, den man sucht. Drei Fehlerfälle sind eingeplant, weil jeder von ihnen ein stummes Programm bedeutet. Der Wachhund baut Taps neu auf, die nur noch Stille liefern — der bekannte Fall aus Spike C. Ein Wechsel des Ausgabegeräts baut alle Taps neu auf, weil sie am alten Gerät hängen. Und ein fehlgeschlagener Aufbau räumt sich selbst weg, statt in einem halben Zustand zu verharren. Beim Beenden von Onyx wird der Mixer abgebaut: sonst bleiben die abgegriffenen Programme stumm zurück. 178 Tests grün, App-Signatur gültig.
277 lines
11 KiB
Swift
277 lines
11 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
|
|
|
|
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() {
|
|
processes = Self.currentProcesses()
|
|
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
|
|
|
|
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 = NSRunningApplication(processIdentifier: pid)?.localizedName ?? bundleID
|
|
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
|
|
}
|
|
}
|