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:
co-authored by
Claude Opus 5
parent
9b600de9a9
commit
edb327cabe
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user