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>
368 lines
16 KiB
Swift
368 lines
16 KiB
Swift
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."
|
||
}
|
||
}
|
||
}
|