From bfa8c9dab1dcbb9d4e8b9320a0f266ea22fea9ef Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Mon, 10 Aug 2026 23:09:48 +0200 Subject: [PATCH] =?UTF-8?q?Phase=207:=20Per-Programm-Audiomixer=20=C3=BCbe?= =?UTF-8?q?r=20Core-Audio-Taps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Onyx.xcodeproj/project.pbxproj | 7 + Onyx/OnyxApp.swift | 8 + Onyx/PlaceholderWidgets.swift | 2 - Packages/OnyxKit/Package.swift | 5 + .../Sources/AudioProvider/AudioMixer.swift | 276 ++++++++++++++++++ .../OnyxKit/Sources/AudioProvider/Gain.swift | 103 +++++++ .../AudioProvider/Localizable.xcstrings | 24 ++ .../Sources/AudioProvider/MixerView.swift | 184 ++++++++++++ .../Sources/AudioProvider/ProcessTap.swift | 203 +++++++++++++ .../Tests/AudioProviderTests/GainTests.swift | 149 ++++++++++ project.yml | 2 + 11 files changed, 961 insertions(+), 2 deletions(-) create mode 100644 Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift create mode 100644 Packages/OnyxKit/Sources/AudioProvider/Gain.swift create mode 100644 Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings create mode 100644 Packages/OnyxKit/Sources/AudioProvider/MixerView.swift create mode 100644 Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift create mode 100644 Packages/OnyxKit/Tests/AudioProviderTests/GainTests.swift diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index 9a488c3..846176c 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */; }; 68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */; }; 6A972372414D6EFDC81E2661 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = B442E8A39C37C8FD92B4CA83 /* main.swift */; }; + 75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 5D8203CC3A09D838A4AC385D /* AudioProvider */; }; 7CE2EDC6BC58678269BF27AD /* MenuBarIcon.svg in Resources */ = {isa = PBXBuildFile; fileRef = 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */; }; 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; }; 9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; }; @@ -79,6 +80,7 @@ C1F651D2BA69358344927F26 /* MediaProvider in Frameworks */, C390C5B65769A007A49C3A80 /* MetricsProvider in Frameworks */, 9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */, + 75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -187,6 +189,7 @@ 349226D70F51196CCD1C94F8 /* MediaProvider */, 6CACA1C2A58DEFDE46F71499 /* MetricsProvider */, FF4E99C8BF0D8944C314A8AF /* NetworkProvider */, + 5D8203CC3A09D838A4AC385D /* AudioProvider */, ); productName = Onyx; productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */; @@ -600,6 +603,10 @@ isa = XCSwiftPackageProductDependency; productName = OnyxDesign; }; + 5D8203CC3A09D838A4AC385D /* AudioProvider */ = { + isa = XCSwiftPackageProductDependency; + productName = AudioProvider; + }; 6CACA1C2A58DEFDE46F71499 /* MetricsProvider */ = { isa = XCSwiftPackageProductDependency; productName = MetricsProvider; diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index a992f85..12a3bbc 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -10,6 +10,7 @@ import WeatherProvider import MediaProvider import MetricsProvider import NetworkProvider +import AudioProvider @main struct OnyxApp: App { @@ -40,6 +41,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var networkModel: NetworkModel? private let settingsWindow = SettingsWindowController() private let fanControl = FanControl() + private let audioMixer = AudioMixer() func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) @@ -140,6 +142,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Sekunden auf Automatik zurück — auch wenn Onyx abstürzt statt sauber // zu beenden. fanControl.stop() + // Ohne das bleiben die abgegriffenen Programme stumm zurück: der Tap + // schaltet das Original stumm, und ohne laufenden Mixer gibt niemand + // mehr etwas aus. + audioMixer.disable() } /// Echte Widgets zuerst, danach die Platzhalter für alles, was noch fehlt. @@ -166,6 +172,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.networkModel = networkModel WidgetRegistry.shared.register(NetworkWidget(model: networkModel)) + WidgetRegistry.shared.register(AudioMixerWidget(mixer: audioMixer)) + PlaceholderWidgets.registerAll() } diff --git a/Onyx/PlaceholderWidgets.swift b/Onyx/PlaceholderWidgets.swift index 77d27c5..83c11c2 100644 --- a/Onyx/PlaceholderWidgets.swift +++ b/Onyx/PlaceholderWidgets.swift @@ -46,8 +46,6 @@ enum PlaceholderWidgets { let widgets: [PlaceholderWidget] = [ .init(id: "fans", displayName: "Lüfter", symbolName: "fan", supportedSizes: [.small, .medium], phase: "Phase 6"), - .init(id: "audio", displayName: "Audio-Mixer", symbolName: "slider.horizontal.3", - supportedSizes: [.medium, .large], phase: "Phase 7"), .init(id: "shelf", displayName: "Ablage", symbolName: "tray.full", supportedSizes: [.small, .medium], phase: "Phase 8"), .init(id: "airdrop", displayName: "AirDrop", symbolName: "shareplay", diff --git a/Packages/OnyxKit/Package.swift b/Packages/OnyxKit/Package.swift index 4d76efd..aa7cae9 100644 --- a/Packages/OnyxKit/Package.swift +++ b/Packages/OnyxKit/Package.swift @@ -19,6 +19,7 @@ let package = Package( .library(name: "MediaProvider", targets: ["MediaProvider"]), .library(name: "MetricsProvider", targets: ["MetricsProvider"]), .library(name: "NetworkProvider", targets: ["NetworkProvider"]), + .library(name: "AudioProvider", targets: ["AudioProvider"]), ], targets: [ // Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt @@ -63,5 +64,9 @@ let package = Package( .target(name: "NetworkProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit", "OnyxMenuBar", "MetricsProvider"], resources: [.process("Localizable.xcstrings")]), .testTarget(name: "NetworkProviderTests", dependencies: ["NetworkProvider"]), + + .target(name: "AudioProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"], + resources: [.process("Localizable.xcstrings")]), + .testTarget(name: "AudioProviderTests", dependencies: ["AudioProvider"]), ] ) diff --git a/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift b/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift new file mode 100644 index 0000000..d755401 --- /dev/null +++ b/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift @@ -0,0 +1,276 @@ +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 = [] + + 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.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 + + // 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(_ 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 + } +} diff --git a/Packages/OnyxKit/Sources/AudioProvider/Gain.swift b/Packages/OnyxKit/Sources/AudioProvider/Gain.swift new file mode 100644 index 0000000..2ab8727 --- /dev/null +++ b/Packages/OnyxKit/Sources/AudioProvider/Gain.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Rechnet Reglerstellung in Verstärkung um. +public enum Gain { + + /// Zweifache Lautstärke ist die Obergrenze. + /// + /// Mehr wäre technisch möglich und praktisch sinnlos: bei doppelter + /// Verstärkung arbeitet der Begrenzer bereits deutlich, darüber bleibt vom + /// Signal nur noch Kompression übrig. + public static let maximumVolume: Double = 2.0 + + /// Verstärkungsfaktor zu einer Reglerstellung von 0 bis 2. + /// + /// Bewusst nicht linear. Lautstärke wird ungefähr logarithmisch + /// wahrgenommen; ein linearer Regler wäre in der unteren Hälfte praktisch + /// wirkungslos und in der oberen viel zu empfindlich. + /// + /// Der Exponent ist hergeleitet, nicht geraten: halbe empfundene Lautstärke + /// entspricht rund −10 dB, also Faktor 0,316. Aus 0,5^k = 0,316 folgt + /// k ≈ 1,66. Damit klingt der halbe Reglerweg auch halb so laut. + /// + /// Eine Potenz statt einer Dezibel-Rechnung, weil hier kein Logarithmus je + /// Abtastwert anfallen soll — der Faktor wird einmal je Puffer bestimmt, + /// aber die Formel steht in einem Echtzeitpfad. + public static let taper: Double = 1.66 + + public static func factor(forVolume volume: Double) -> Double { + let clamped = min(max(volume, 0), maximumVolume) + guard clamped > 0 else { return 0 } + return pow(clamped, taper) + } +} + +/// Weiche Begrenzung. +/// +/// Bei zweifacher Verstärkung entstehen Abtastwerte weit über dem gültigen +/// Bereich. Ungebremst schneidet die Hardware sie hart ab, und das knackt +/// hörbar. Der Tangens hyperbolicus biegt sie stattdessen sanft um: leise +/// Signale bleiben unangetastet, laute werden zunehmend gestaucht. +public enum SoftClip { + + /// Ab hier greift die Kennlinie. Darunter bleibt das Signal unverändert — + /// eine Begrenzung, die schon bei Zimmerlautstärke arbeitet, klingt matt. + public static let threshold: Float = 0.7 + + public static func apply(_ sample: Float) -> Float { + let magnitude = abs(sample) + guard magnitude > threshold else { return sample } + + let excess = magnitude - threshold + let headroom = 1 - threshold + let compressed = threshold + headroom * tanh(excess / headroom) + return sample < 0 ? -compressed : compressed + } +} + +/// Merkt sich Lautstärke und Stummschaltung je Programm. +/// +/// Nach der Bundle-Kennung, nicht nach Prozessnummer: die ändert sich bei +/// jedem Start, und eine Einstellung, die einen Neustart des Programms nicht +/// überlebt, ist keine. +public struct VolumeStore: Sendable { + + private var volumes: [String: Double] = [:] + private var muted: Set = [] + + public init() {} + + /// Unbekannte Programme starten auf voller Lautstärke — nicht auf null. + /// Ein Programm, das nach dem Start stumm ist, wirkt kaputt, und niemand + /// sucht die Ursache im Mixer. + public func volume(for bundleID: String) -> Double { + volumes[bundleID] ?? 1.0 + } + + public func isMuted(for bundleID: String) -> Bool { muted.contains(bundleID) } + + public mutating func set(_ volume: Double, for bundleID: String) { + volumes[bundleID] = min(max(volume, 0), Gain.maximumVolume) + } + + public mutating func setMuted(_ isMuted: Bool, for bundleID: String) { + // Getrennt von der Lautstärke: sonst verliert man beim Stummschalten + // den eingestellten Wert und muss ihn danach neu suchen. + if isMuted { muted.insert(bundleID) } else { muted.remove(bundleID) } + } + + /// Was der Audiopfad tatsächlich anwenden soll. + public func effectiveGain(for bundleID: String) -> Double { + isMuted(for: bundleID) ? 0 : Gain.factor(forVolume: volume(for: bundleID)) + } + + // MARK: - Sicherung + + public var snapshot: [String: Double] { volumes } + public var mutedIdentifiers: [String] { Array(muted) } + + public init(volumes: [String: Double], muted: [String]) { + self.volumes = volumes + self.muted = Set(muted) + } +} diff --git a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings new file mode 100644 index 0000000..f2c7fb3 --- /dev/null +++ b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings @@ -0,0 +1,24 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "mixer.name" : { "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Audio-Mixer" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Audio mixer" } } } }, + "mixer.on" : { "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Mixer einschalten" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Turn mixer on" } } } }, + "mixer.off" : { "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Mixer ausschalten" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Turn mixer off" } } } }, + "mixer.off.explain" : { "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Regelt die Lautstärke einzelner Programme, auch über 100 %." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Sets the volume of individual apps, including above 100%." } } } }, + "mixer.off.warning" : { "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Dafür greift Onyx in den Tonweg der ausgewählten Programme ein. Beim Ausschalten wird der ursprüngliche Zustand sofort wiederhergestellt." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Onyx intercepts the audio path of the selected apps to do this. Turning it off restores the original state immediately." } } } }, + "mixer.noProcesses" : { "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Kein Programm gibt gerade Ton aus" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "No app is playing audio" } } } } + }, + "version" : "1.0" +} diff --git a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift new file mode 100644 index 0000000..15bc12a --- /dev/null +++ b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift @@ -0,0 +1,184 @@ +import SwiftUI +import AppKit +import OnyxDesign +import OnyxWidgetKit + +/// Die Mischpult-Ansicht. Dieselbe im Panel und im Menüleisten-Popover. +public struct MixerView: View { + let mixer: AudioMixer + /// Im Panel ist es eng, im Popover nicht. + let compact: Bool + + public init(mixer: AudioMixer, compact: Bool = false) { + self.mixer = mixer + self.compact = compact + } + + public var body: some View { + VStack(alignment: .leading, spacing: compact ? 6 : 10) { + if mixer.isEnabled { + if mixer.processes.isEmpty { + Text("mixer.noProcesses", bundle: .module) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textTertiary) + } else { + ForEach(mixer.processes.prefix(compact ? 4 : 10)) { process in + ProcessRow(mixer: mixer, process: process, compact: compact) + } + } + + if let error = mixer.lastError { + Text(error).font(Onyx.Font.caption).foregroundStyle(Onyx.Color.critical) + .fixedSize(horizontal: false, vertical: true) + } + + Divider().overlay(Onyx.Color.hairline) + // Der Notausschalter gehört sichtbar dazu, nicht in ein Menü: + // wenn der Ton weg ist, ist das der Knopf, den man sucht. + Button { mixer.disable() } label: { + Label("mixer.off", systemImage: "power") + .font(Onyx.Font.caption) + } + .buttonStyle(.plain) + .foregroundStyle(Onyx.Color.textSecondary) + } else { + OffState(mixer: mixer, compact: compact) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// Der ausgeschaltete Zustand erklärt, worauf man sich einlässt. +private struct OffState: View { + let mixer: AudioMixer + let compact: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("mixer.off.explain", bundle: .module) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textSecondary) + .fixedSize(horizontal: false, vertical: true) + + if !compact { + Text("mixer.off.warning", bundle: .module) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + + Button { mixer.enable() } label: { + Label("mixer.on", systemImage: "slider.horizontal.3").font(Onyx.Font.caption) + } + .buttonStyle(.plain) + .foregroundStyle(Onyx.Color.accent) + } + } +} + +private struct ProcessRow: View { + let mixer: AudioMixer + let process: AudioProcess + let compact: Bool + + var body: some View { + let managed = mixer.isManaged(process.bundleID) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + icon + Text(process.name) + .font(Onyx.Font.caption) + .foregroundStyle(process.isPlaying ? Onyx.Color.textPrimary + : Onyx.Color.textSecondary) + .lineLimit(1) + + if process.isPlaying { + Circle().fill(Onyx.Color.positive).frame(width: 5, height: 5) + } + Spacer(minLength: 0) + + if managed { + Text("\(Int(mixer.volume(for: process.bundleID) * 100)) %") + .font(Onyx.Font.metricSmall) + .foregroundStyle(mixer.volume(for: process.bundleID) > 1 + ? Onyx.Color.warning : Onyx.Color.textTertiary) + + Button { + mixer.setMuted(!mixer.isMuted(process.bundleID), for: process.bundleID) + } label: { + Image(systemName: mixer.isMuted(process.bundleID) + ? "speaker.slash.fill" : "speaker.wave.2.fill") + .font(.system(size: 10)) + } + .buttonStyle(.plain) + .foregroundStyle(mixer.isMuted(process.bundleID) + ? Onyx.Color.critical : Onyx.Color.textTertiary) + } + + // Erst wenn ein Programm ausgewählt ist, wird sein Ton + // abgegriffen. Alles andere läuft unverändert am Mixer vorbei. + Toggle("", isOn: Binding( + get: { managed }, + set: { mixer.setManaged($0, for: process.bundleID) })) + .toggleStyle(.switch) + .controlSize(.mini) + .labelsHidden() + } + + if managed { + Slider(value: Binding( + get: { mixer.volume(for: process.bundleID) }, + set: { mixer.setVolume($0, for: process.bundleID) }), + in: 0...Gain.maximumVolume) + .controlSize(.mini) + .disabled(mixer.isMuted(process.bundleID)) + .overlay(alignment: .leading) { + // Markierung bei 100 %: darüber wird verstärkt, und + // man soll sehen, wann man diese Grenze überschreitet. + GeometryReader { geometry in + Rectangle() + .fill(Onyx.Color.hairline) + .frame(width: 1, height: 8) + .offset(x: geometry.size.width / Gain.maximumVolume, y: 4) + } + } + } + } + } + + @ViewBuilder + private var icon: some View { + if let app = NSRunningApplication(processIdentifier: process.pid), + let image = app.icon { + Image(nsImage: image).resizable().frame(width: 14, height: 14) + } else { + Image(systemName: "app.dashed") + .font(.system(size: 11)) + .foregroundStyle(Onyx.Color.textTertiary) + } + } +} + +// MARK: - Widget + +public struct AudioMixerWidget: OnyxWidget { + public let id = "audio" + public var displayName: String { String(localized: "mixer.name", bundle: .module) } + public let symbolName = "slider.horizontal.3" + public let supportedSizes: [WidgetSize] = [.medium, .large] + + private let mixer: AudioMixer + + public init(mixer: AudioMixer) { self.mixer = mixer } + + public func makeView(size: WidgetSize) -> AnyView { + AnyView( + ScrollView { + MixerView(mixer: mixer, compact: size == .medium) + } + .scrollIndicators(.never) + ) + } +} diff --git a/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift b/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift new file mode 100644 index 0000000..4c8fe9a --- /dev/null +++ b/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift @@ -0,0 +1,203 @@ +import Foundation +import AudioToolbox +import CoreAudio +import AVFoundation +import OSLog + +private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio") + +/// Ein Programm, das Ton ausgibt. +public struct AudioProcess: Identifiable, Equatable, Sendable { + public let objectID: AudioObjectID + public let pid: pid_t + public let bundleID: String + public let name: String + public let isPlaying: Bool + + public var id: AudioObjectID { objectID } +} + +/// Fängt den Ton **eines** Programms ab, verstärkt ihn und gibt ihn wieder aus. +/// +/// Der Weg ist der einzige, den macOS ohne Treiber anbietet: ein Process Tap +/// mit `mutedWhenTapped` schaltet die ursprüngliche Ausgabe stumm und liefert +/// das Signal stattdessen hier ab. Ein privates Aggregate-Device aus Tap und +/// echtem Ausgabegerät gibt es nach der Bearbeitung wieder aus. +/// +/// **Fällt dieser Aufbau auseinander, ist das Programm still.** Deshalb wird +/// jeder Fehler zum Abbau des Taps führen, nicht zu einem halben Zustand — und +/// deshalb gibt es einen Wachhund gegen die bekannten Null-Puffer. +final class ProcessTap { + + let process: AudioProcess + /// Wird im Echtzeitpfad gelesen. `atomic` genügt hier nicht als Sprachmittel, + /// aber ein einzelner `Float` wird auf arm64 atomar geschrieben. + private(set) var gain: Float = 1 + + private var tapID = AudioObjectID(kAudioObjectUnknown) + private var aggregateID = AudioObjectID(kAudioObjectUnknown) + private var procID: AudioDeviceIOProcID? + private let tapUUID = UUID() + + /// Wann zuletzt ein Puffer mit Inhalt kam — für den Wachhund. + private(set) var lastAudioAt = Date() + private(set) var isRunning = false + + init(process: AudioProcess) { + self.process = process + } + + deinit { stop() } + + func setGain(_ value: Double) { + gain = Float(value) + } + + // MARK: - Aufbau + + func start(outputDeviceUID: String) throws { + guard !isRunning else { return } + + let description = CATapDescription(stereoMixdownOfProcesses: [process.objectID]) + description.uuid = tapUUID + description.name = "Onyx – \(process.name)" + description.isPrivate = true + // Der Kern des Ganzen: das Original wird stumm geschaltet, das Signal + // kommt nur noch hier an. Ohne das hörte man beides gleichzeitig. + description.muteBehavior = .mutedWhenTapped + + var tap = AudioObjectID(kAudioObjectUnknown) + let tapStatus = AudioHardwareCreateProcessTap(description, &tap) + guard tapStatus == noErr else { throw AudioMixerError.tapFailed(tapStatus) } + tapID = tap + + let aggregate: [String: Any] = [ + kAudioAggregateDeviceNameKey: "Onyx Mixer – \(process.name)", + kAudioAggregateDeviceUIDKey: UUID().uuidString, + // Privat: das Gerät soll nicht in der Tonauswahl des Systems + // auftauchen und auch nicht von anderen Programmen gewählt werden. + kAudioAggregateDeviceIsPrivateKey: true, + kAudioAggregateDeviceIsStackedKey: false, + kAudioAggregateDeviceMainSubDeviceKey: outputDeviceUID, + kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputDeviceUID]], + kAudioAggregateDeviceTapListKey: [[ + kAudioSubTapUIDKey: tapUUID.uuidString, + kAudioSubTapDriftCompensationKey: true, + ]], + ] + + var device = AudioObjectID(kAudioObjectUnknown) + let aggregateStatus = AudioHardwareCreateAggregateDevice(aggregate as CFDictionary, &device) + guard aggregateStatus == noErr else { + cleanUp() + throw AudioMixerError.aggregateFailed(aggregateStatus) + } + aggregateID = device + + var proc: AudioDeviceIOProcID? + let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) { + [weak self] _, inputData, _, outputData, _ in + self?.render(input: inputData, output: outputData) + } + guard ioStatus == noErr, let proc else { + cleanUp() + throw AudioMixerError.ioProcFailed(ioStatus) + } + procID = proc + + guard AudioDeviceStart(aggregateID, proc) == noErr else { + cleanUp() + throw AudioMixerError.startFailed + } + + isRunning = true + lastAudioAt = Date() + log.notice("Tap aktiv für \(self.process.name, privacy: .public)") + } + + // MARK: - Echtzeitpfad + + /// Läuft auf dem Audiothread. Hier darf nichts geschehen, was blockieren + /// kann: keine Speicheranforderung, keine Sperre, kein Protokollieren. + private func render(input: UnsafePointer, + output: UnsafeMutablePointer) { + let inputList = UnsafeMutableAudioBufferListPointer( + UnsafeMutablePointer(mutating: input)) + let outputList = UnsafeMutableAudioBufferListPointer(output) + + let factor = gain + var sawAudio = false + + for (index, outputBuffer) in outputList.enumerated() { + guard index < inputList.count, + let source = inputList[index].mData, + let destination = outputBuffer.mData else { continue } + + let count = Int(min(inputList[index].mDataByteSize, outputBuffer.mDataByteSize)) + / MemoryLayout.size + let inputSamples = source.assumingMemoryBound(to: Float.self) + let outputSamples = destination.assumingMemoryBound(to: Float.self) + + for sample in 0.. 1 ? SoftClip.apply(value) : value + if !sawAudio, value != 0 { sawAudio = true } + } + } + + if sawAudio { lastAudioAt = Date() } + } + + // MARK: - Abbau + + func stop() { + guard tapID != kAudioObjectUnknown || aggregateID != kAudioObjectUnknown else { return } + cleanUp() + isRunning = false + } + + /// Immer in dieser Reihenfolge: erst anhalten, dann IOProc, dann Gerät, + /// dann Tap. Umgekehrt bleibt das Programm stumm zurück, weil der Tap noch + /// stummschaltet, aber nichts mehr ausgibt. + private func cleanUp() { + if let procID { + AudioDeviceStop(aggregateID, procID) + AudioDeviceDestroyIOProcID(aggregateID, procID) + self.procID = nil + } + if aggregateID != kAudioObjectUnknown { + AudioHardwareDestroyAggregateDevice(aggregateID) + aggregateID = AudioObjectID(kAudioObjectUnknown) + } + if tapID != kAudioObjectUnknown { + AudioHardwareDestroyProcessTap(tapID) + tapID = AudioObjectID(kAudioObjectUnknown) + } + } +} + +public enum AudioMixerError: Error, LocalizedError { + case tapFailed(OSStatus) + case aggregateFailed(OSStatus) + case ioProcFailed(OSStatus) + case startFailed + case noOutputDevice + + public var errorDescription: String? { + switch self { + case .tapFailed(let status): + "Der Ton dieses Programms lässt sich nicht abgreifen (\(status))." + case .aggregateFailed(let status): + "Das Mischgerät ließ sich nicht anlegen (\(status))." + case .ioProcFailed(let status): + "Die Tonverarbeitung ließ sich nicht starten (\(status))." + case .startFailed: + "Das Mischgerät startet nicht." + case .noOutputDevice: + "Kein Ausgabegerät gefunden." + } + } +} diff --git a/Packages/OnyxKit/Tests/AudioProviderTests/GainTests.swift b/Packages/OnyxKit/Tests/AudioProviderTests/GainTests.swift new file mode 100644 index 0000000..8302345 --- /dev/null +++ b/Packages/OnyxKit/Tests/AudioProviderTests/GainTests.swift @@ -0,0 +1,149 @@ +import Testing +import Foundation +@testable import AudioProvider + +// Hier wird in den Ton eingegriffen, der aus den Lautsprechern kommt. Ein +// Rechenfehler klingt nicht falsch — er knackt, verzerrt oder ist schmerzhaft +// laut. Deshalb liegt die Rechnung als reine Funktion vor und wird vollständig +// durchgeprüft, bevor sie an einen Audiopuffer kommt. + +@Suite("Verstärkung") +struct GainTests { + + @Test("Volle Lautstärke lässt das Signal unverändert") + func unityIsTransparent() { + #expect(Gain.factor(forVolume: 1.0) == 1.0) + } + + @Test("Stumm ist wirklich still") + func zeroIsSilent() { + #expect(Gain.factor(forVolume: 0) == 0) + } + + @Test("Die Skala ist wahrnehmungsgerecht, nicht linear") + func scaleIsPerceptual() { + // Halber Reglerweg soll halb so laut klingen, nicht halbe Amplitude + // haben — das sind rund −10 dB, also etwa Faktor 0,3. Linear wäre der + // Regler in der unteren Hälfte praktisch wirkungslos. + let half = Gain.factor(forVolume: 0.5) + #expect(half > 0.2 && half < 0.4) + } + + @Test("Die Skala steigt durchgehend") + func scaleIsMonotonic() { + var previous = -1.0 + for step in 0...100 { + let value = Gain.factor(forVolume: Double(step) / 100) + #expect(value >= previous) + previous = value + } + } + + @Test("Über 100 Prozent wird tatsächlich verstärkt") + func boostAmplifies() { + #expect(Gain.factor(forVolume: 2.0) > 1.0) + } + + @Test("Der Regelbereich ist begrenzt — auch bei absurder Eingabe") + func rangeIsClamped() { + #expect(Gain.factor(forVolume: -5) == 0) + #expect(Gain.factor(forVolume: 99) == Gain.factor(forVolume: Gain.maximumVolume)) + } +} + +@Suite("Begrenzer") +struct LimiterTests { + + @Test("Leise Signale gehen unverändert durch") + func quietPassesThrough() { + #expect(abs(SoftClip.apply(0.3) - 0.3) < 0.001) + #expect(abs(SoftClip.apply(-0.3) + 0.3) < 0.001) + } + + @Test("Nichts verlässt je den gültigen Bereich") + func neverExceedsFullScale() { + // Der eigentliche Zweck: bei 200 Prozent entstehen Werte weit über 1,0. + // Ungebremst werden die von der Hardware hart abgeschnitten, und das + // knackt hörbar. + for value in stride(from: -8.0, through: 8.0, by: 0.05) { + let result = SoftClip.apply(Float(value)) + #expect(result <= 1.0 && result >= -1.0) + } + } + + @Test("Die Kennlinie ist punktsymmetrisch") + func curveIsSymmetric() { + for value in stride(from: 0.0, through: 4.0, by: 0.1) { + let positive = SoftClip.apply(Float(value)) + let negative = SoftClip.apply(Float(-value)) + #expect(abs(positive + negative) < 0.0001) + } + } + + @Test("Die Kennlinie steigt durchgehend — sonst klingt sie verzerrt") + func curveIsMonotonic() { + var previous = Float(-2) + for value in stride(from: -6.0, through: 6.0, by: 0.01) { + let result = SoftClip.apply(Float(value)) + #expect(result >= previous - 0.0001) + previous = result + } + } + + @Test("Null bleibt null") + func zeroStaysZero() { + #expect(SoftClip.apply(0) == 0) + } +} + +@Suite("Lautstärke-Gedächtnis") +struct VolumeStoreTests { + + @Test("Ein gemerkter Wert kommt zurück") + func roundTrip() { + var store = VolumeStore() + store.set(0.4, for: "com.spotify.client") + #expect(store.volume(for: "com.spotify.client") == 0.4) + } + + @Test("Ein unbekanntes Programm startet auf voller Lautstärke") + func unknownDefaultsToFull() { + // Nicht auf null: ein Programm, das nach dem Start stumm ist, wirkt + // kaputt, und niemand sucht die Ursache im Mixer. + #expect(VolumeStore().volume(for: "com.example.neu") == 1.0) + } + + @Test("Stummschaltung ist getrennt von der Lautstärke") + func muteIsSeparate() { + // Sonst verliert man beim Stummschalten den eingestellten Wert und + // muss ihn danach neu suchen. + var store = VolumeStore() + store.set(0.6, for: "app") + store.setMuted(true, for: "app") + + #expect(store.isMuted(for: "app")) + #expect(store.volume(for: "app") == 0.6) + + store.setMuted(false, for: "app") + #expect(store.volume(for: "app") == 0.6) + } + + @Test("Die wirksame Verstärkung ist null, solange stumm geschaltet ist") + func mutedMeansSilent() { + var store = VolumeStore() + store.set(0.8, for: "app") + store.setMuted(true, for: "app") + + #expect(store.effectiveGain(for: "app") == 0) + } + + @Test("Werte außerhalb des Bereichs werden eingefangen") + func valuesAreClamped() { + var store = VolumeStore() + store.set(-1, for: "app") + #expect(store.volume(for: "app") == 0) + + store.set(999, for: "app") + #expect(store.volume(for: "app") == Gain.maximumVolume) + } +} diff --git a/project.yml b/project.yml index cc4239b..13a1029 100644 --- a/project.yml +++ b/project.yml @@ -95,6 +95,8 @@ targets: product: MetricsProvider - package: OnyxKit product: NetworkProvider + - package: OnyxKit + product: AudioProvider postBuildScripts: # Der Helfer und seine Plist müssen an genau diese Stellen: SMAppService # sucht die Plist unter Contents/Library/LaunchDaemons und liest von dort