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>
This commit is contained in:
Scarriffle
2026-09-05 21:05:51 +02:00
co-authored by Claude Opus 5
parent 9b600de9a9
commit edb327cabe
32 changed files with 3677 additions and 968 deletions
@@ -56,6 +56,15 @@ final class ProcessTap {
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
@@ -131,6 +140,24 @@ final class ProcessTap {
}
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
@@ -149,7 +176,7 @@ final class ProcessTap {
isRunning = true
lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed)
log.notice("Tap aktiv für \(self.process.name, privacy: .public)")
log.notice("Tap aktiv für \(self.process.name, privacy: .public), Eingangspuffer \(self.tapBufferOffset, privacy: .public)")
}
// MARK: - Echtzeitpfad
@@ -166,11 +193,14 @@ final class ProcessTap {
var peak: Float = 0
for (index, outputBuffer) in outputList.enumerated() {
guard index < inputList.count,
let source = inputList[index].mData,
// 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[index].mDataByteSize, outputBuffer.mDataByteSize))
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)
@@ -224,6 +254,89 @@ final class ProcessTap {
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
}
}
@@ -233,6 +346,7 @@ public enum AudioMixerError: Error, LocalizedError {
case ioProcFailed(OSStatus)
case startFailed
case noOutputDevice
case unexpectedLayout
public var errorDescription: String? {
switch self {
@@ -246,6 +360,8 @@ public enum AudioMixerError: Error, LocalizedError {
"Das Mischgerät startet nicht."
case .noOutputDevice:
"Kein Ausgabegerät gefunden."
case .unexpectedLayout:
"Das Mischgerät reicht den Ton an unerwarteter Stelle durch."
}
}
}