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:
203
Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift
Normal file
203
Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user