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