Files
onyx/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift
T
ScarriffleandClaude Opus 5 edb327cabe Kacheln und Fenster sprechen dieselbe Sprache
Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund
hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt
Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl.

Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren
deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht
mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz
auf schwarz endet.

Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das
lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient,
bis jede Kachel einmal zu sehen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:05:51 +02:00

368 lines
16 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 {
/// Vorsatz der Gerätekennungen, die Onyx selbst anlegt.
static let aggregateUIDPrefix = "com.scarriffleservices.onyx.tap."
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()
/// Der erste Eingangspuffer, der dem Tap gehört.
///
/// **Nicht immer null.** Bringt das Ausgabegerät selbst einen Eingang mit —
/// jedes Headset tut das, jedes USB-Interface auch —, dann reiht das
/// Aggregate-Gerät dessen Puffer vor die des Taps. Wird in `start()`
/// bestimmt und im Echtzeitpfad nur gelesen; geschrieben wird ausschließlich
/// vor `AudioDeviceStart` und nach `AudioDeviceStop`, also nie nebenläufig.
private var tapBufferOffset = 0
/// 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, 0…1. 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)",
// Kennung mit erkennbarem Vorsatz: die Geräte sind zwar privat,
// das verbirgt sie aber nur vor fremden Prozessen. Onyx selbst
// sieht sie in der Geräteliste und muss sie dort aussortieren
// können — über den Namen ginge das nur unzuverlässig.
kAudioAggregateDeviceUIDKey: Self.aggregateUIDPrefix + 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
// Wo der Tap im Eingang liegt, steht erst fest, wenn das Gerät gebaut
// ist. Danach fragen, vorher nichts starten: liegt er nicht dort, wo
// wir ihn erwarten, wäre die Folge Stille bei stummgeschaltetem
// Original — genau der Fehler, der am Headset auftrat.
guard let subDeviceID = Self.device(forUID: outputDeviceUID),
let subDeviceInputs = Self.inputChannelCounts(subDeviceID),
let aggregateInputs = Self.inputChannelCounts(aggregateID),
let tapChannels = Self.tapChannels(tapID),
let offset = TapBufferLayout.offset(
subDeviceInputBuffers: subDeviceInputs.count,
aggregateInputChannels: aggregateInputs,
tapChannels: tapChannels)
else {
cleanUp()
throw AudioMixerError.unexpectedLayout
}
tapBufferOffset = offset
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), Eingangspuffer \(self.tapBufferOffset, 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() {
// Der Ausgang zählt ab null, der Eingang ab dem Puffer des Taps.
// Ohne den Versatz stünde hier bei einem Headset das Mikrofon.
let sourceIndex = tapBufferOffset + index
guard sourceIndex < inputList.count,
let source = inputList[sourceIndex].mData,
let destination = outputBuffer.mData else { continue }
let count = Int(min(inputList[sourceIndex].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)
}
tapBufferOffset = 0
}
// MARK: - Core Audio
private static func device(forUID uid: String) -> AudioObjectID? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyTranslateUIDToDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var qualifier = uid as CFString
var device = AudioObjectID(kAudioObjectUnknown)
var size = UInt32(MemoryLayout<AudioObjectID>.size)
let status = withUnsafeMutablePointer(to: &qualifier) {
AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address,
UInt32(MemoryLayout<CFString>.size), $0, &size, &device)
}
guard status == noErr, device != AudioObjectID(kAudioObjectUnknown) else { return nil }
return device
}
/// Die Kanäle je Eingangspuffer eines Geräts. Leer heißt: kein Eingang —
/// das ist ein gültiges Ergebnis, `nil` heißt, es ließ sich nicht lesen.
private static func inputChannelCounts(_ device: AudioObjectID) -> [UInt32]? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioObjectPropertyScopeInput,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr
else { return nil }
guard size > 0 else { return [] }
let raw = UnsafeMutableRawPointer.allocate(
byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
defer { raw.deallocate() }
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, raw) == noErr
else { return nil }
let list = UnsafeMutableAudioBufferListPointer(
raw.assumingMemoryBound(to: AudioBufferList.self))
return list.map(\.mNumberChannels)
}
private static func tapChannels(_ tap: AudioObjectID) -> UInt32? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioTapPropertyFormat,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var format = AudioStreamBasicDescription()
var size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
guard AudioObjectGetPropertyData(tap, &address, 0, nil, &size, &format) == noErr
else { return nil }
return format.mChannelsPerFrame
}
}
/// Wo im Eingang eines Aggregate-Geräts die Puffer des Taps liegen.
///
/// Ein Aggregate-Gerät reiht seine Eingangspuffer in fester Ordnung: erst die
/// des Untergeräts, dann die des Taps. Bei den eingebauten Lautsprechern gibt
/// es keine eigenen — dort liegt der Tap an null, und genau daran hat sich der
/// erste Entwurf gewöhnt. Ein Headset bringt sein Mikrofon mit und schiebt den
/// Tap eine Stelle weiter.
///
/// Als reine Funktion, weil sie sonst nur mit angestecktem Headset prüfbar wäre.
enum TapBufferLayout {
/// - Returns: der Index des ersten Tap-Puffers, oder `nil`, wenn die
/// Aufteilung nicht der erwarteten entspricht. Dann wird **nicht geraten**:
/// ein falsch geratener Puffer heißt Stille bei stummgeschaltetem
/// Original, ein abgewiesener Tap heißt nur, dass dieses Programm
/// ungeregelt bleibt — hörbar.
static func offset(subDeviceInputBuffers: Int,
aggregateInputChannels: [UInt32],
tapChannels: UInt32) -> Int? {
guard subDeviceInputBuffers >= 0, tapChannels > 0 else { return nil }
guard subDeviceInputBuffers < aggregateInputChannels.count else { return nil }
// Die Gegenprobe: an der erwarteten Stelle muss das Format des Taps
// stehen. Läge die Ordnung anders, fiele das hier auf, statt still zu
// bleiben.
guard aggregateInputChannels[subDeviceInputBuffers] == tapChannels else { return nil }
return subDeviceInputBuffers
}
}
public enum AudioMixerError: Error, LocalizedError {
case tapFailed(OSStatus)
case aggregateFailed(OSStatus)
case ioProcFailed(OSStatus)
case startFailed
case noOutputDevice
case unexpectedLayout
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."
case .unexpectedLayout:
"Das Mischgerät reicht den Ton an unerwarteter Stelle durch."
}
}
}