Files
onyx/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift
Scarriffle a0342205fd Phase 9: Einrichtung, Autostart, Notarisierung — und die Mixer-Optik
Die Einrichtung beim ersten Start erklärt jede der vier Sachen einzeln und
fragt jede einzeln an. Vier Systemdialoge hintereinander wären der übliche
Weg und der falsche: wer nicht weiß wofür, klickt viermal „Nicht erlauben",
und danach fragt macOS nie wieder. Sie kommt beim ersten Start immer, auch
wenn zufällig schon alles erlaubt ist — sie erklärt auch den Helfer und den
Autostart.

Beim Autostart ist `requiresApproval` der Zustand, der weh tut. Wer das
Anmeldeobjekt in den Systemeinstellungen abgeschaltet hat, kann in Onyx auf
den Schalter drücken, so oft er will. Als „aus" angezeigt führt das in eine
Schleife, also steht dort jetzt, wo es weitergeht.

Der Mixer verliert den Schalter je Programm. Zehn Mini-Schalter untereinander
sind das, was den Eindruck macht; am Regler zu ziehen ist ohnehin die
Entscheidung, dieses Programm zu regeln, und der große Ausschalter stellt
alles zurück. Der Regler ist selbst gezeichnet — der Systemregler bringt sein
eigenes Erscheinungsbild mit und kann nicht zeigen, worauf es hier ankommt:
den Pegel und die Grenze bei 100 %.

Der Pegel wird im selben Durchlauf gemessen, in dem verstärkt wird, und über
`Atomic` weitergereicht. Eine gewöhnliche Eigenschaft wäre ein Wettlauf, eine
Sperre im Audiothread der klassische Weg zu Aussetzern. Dabei fällt das
`Date()` aus dem Echtzeitpfad, das dort nie hingehört hat.

Zwei Funde beim Bauen des Release-Wegs, beide hätten erst nach dem Hochladen
zur Ablehnung geführt: der Helfer wurde ohne sicheren Zeitstempel signiert,
und das Adapter-Framework ad-hoc. Beides gilt jetzt nur noch im Debug.
Scripts/release.sh prüft Zertifikat und Notarisierungsprofil vorweg, statt
nach zehn Minuten Bauen zu scheitern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:36:26 +02:00

244 lines
9.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Synchronization
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
/// Der höchste Ausschlag seit dem letzten Abholen als Bitmuster, weil
/// `Atomic` keine Fließkommazahlen führt.
///
/// Sperrenfrei und damit im Echtzeitpfad erlaubt. Eine gewöhnliche
/// Eigenschaft zu beschreiben wäre ein Wettlauf mit dem Leser, und eine
/// Sperre im Audiothread ist der klassische Weg zu Aussetzern.
private let peakBits = Atomic<UInt32>(0)
/// Wann zuletzt Ton kam in Sekunden seit dem Programmstart, damit der
/// Echtzeitpfad keine `Date` anfassen muss.
private let lastAudioUptime = Atomic<UInt64>(0)
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 isRunning = false
/// Wie lange dieser Tap schon nur Stille liefert.
var secondsSinceAudio: TimeInterval {
let stored = lastAudioUptime.load(ordering: .relaxed)
guard stored > 0 else { return 0 }
return max(0, Self.uptime() - Double(stored) / 1000)
}
/// Der Ausschlag seit dem letzten Abruf, 01. Holt ihn ab und setzt zurück,
/// damit die Anzeige abfällt, statt auf dem Höchstwert stehen zu bleiben.
func takePeak() -> Float {
Float(bitPattern: peakBits.exchange(0, ordering: .relaxed))
}
private static func uptime() -> Double {
Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000
}
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
lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed)
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 peak: Float = 0
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.
let limited = factor > 1 ? SoftClip.apply(value) : value
outputSamples[sample] = limited
// Der Ausschlag wird im selben Durchlauf mitgenommen: ein
// zweiter Durchgang nur zum Messen wäre im Echtzeitpfad die
// teuerste Art, an dieselbe Zahl zu kommen.
let magnitude = abs(limited)
if magnitude > peak { peak = magnitude }
}
}
if peak > 0 {
// Höchstwert behalten, bis die Anzeige ihn abholt sonst
// verschluckt ein Puffer mit Stille die Spitze davor.
let previous = Float(bitPattern: peakBits.load(ordering: .relaxed))
if peak > previous { peakBits.store(peak.bitPattern, ordering: .relaxed) }
lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed)
}
}
// 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."
}
}
}