Phase 7: Per-Programm-Audiomixer über Core-Audio-Taps

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.
This commit is contained in:
Guido Schmit
2026-08-10 23:09:48 +02:00
parent f48b29e53e
commit bfa8c9dab1
11 changed files with 961 additions and 2 deletions

View File

@@ -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)
)
}
}