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,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<AudioBufferList>,
output: UnsafeMutablePointer<AudioBufferList>) {
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<Float>.size
let inputSamples = source.assumingMemoryBound(to: Float.self)
let outputSamples = destination.assumingMemoryBound(to: Float.self)
for sample in 0..<count {
let value = inputSamples[sample] * factor
// Nur begrenzen, wenn wirklich verstärkt wird unterhalb der
// Schwelle ist die Kennlinie ohnehin die Identität, und im
// Echtzeitpfad zählt jede eingesparte Rechnung.
outputSamples[sample] = factor > 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."
}
}
}