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
@@ -31,10 +31,12 @@ public struct MixerView: View {
|
||||
// Mixer-Schalters: sie brauchen keinen Tap, keine Berechtigung und
|
||||
// keinen laufenden Mixer. Wer nur die Lautstärke sucht, soll dafür
|
||||
// nichts einschalten müssen.
|
||||
SectionTitle("mixer.section.system", compact: compact)
|
||||
OnyxSectionTitle(String(localized: "mixer.section.system", bundle: .module))
|
||||
.padding(.top, compact ? 0 : Onyx.Space.xxs)
|
||||
SystemSection(system: system, compact: compact)
|
||||
|
||||
SectionTitle("mixer.section.apps", compact: compact)
|
||||
OnyxSectionTitle(String(localized: "mixer.section.apps", bundle: .module))
|
||||
.padding(.top, compact ? 0 : Onyx.Space.xxs)
|
||||
|
||||
if mixer.isEnabled {
|
||||
if mixer.groups.isEmpty {
|
||||
@@ -42,24 +44,20 @@ public struct MixerView: View {
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.vertical, Onyx.Space.md)
|
||||
} else {
|
||||
// Eine Karte um die Zeilen: sie fasst zusammen, was
|
||||
// zusammengehört, und trennt es vom Ausschalter darunter.
|
||||
VStack(spacing: 0) {
|
||||
OnyxCard {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()),
|
||||
id: \.element.id) { index, group in
|
||||
if index > 0 { Divider().overlay(Onyx.Color.hairline) }
|
||||
if index > 0 { OnyxHairline() }
|
||||
ProcessRow(mixer: mixer, group: group, compact: compact)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, compact ? 6 : 8)
|
||||
.padding(.horizontal, Onyx.Space.row)
|
||||
.padding(.vertical, compact ? Onyx.Space.sm : Onyx.Space.md)
|
||||
}
|
||||
}
|
||||
.background(Onyx.Color.elevated.opacity(0.55),
|
||||
in: .rect(cornerRadius: 10, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +76,7 @@ public struct MixerView: View {
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 2)
|
||||
.padding(.top, Onyx.Space.xxs)
|
||||
} else {
|
||||
OffState(mixer: mixer, compact: compact)
|
||||
autostartToggle
|
||||
@@ -128,10 +126,10 @@ private struct OffState: View {
|
||||
let compact: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.row) {
|
||||
HStack(spacing: Onyx.Space.md) {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.font(.system(size: 15, weight: .light))
|
||||
.font(.system(size: Onyx.Symbol.large, weight: .light))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Text("mixer.off.explain", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
@@ -146,15 +144,7 @@ private struct OffState: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Button { mixer.enable() } label: {
|
||||
Text("mixer.on", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.surface)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(Onyx.Color.accent, in: .capsule)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
OnyxPillButton(String(localized: "mixer.on", bundle: .module)) { mixer.enable() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,7 +164,7 @@ private struct ProcessRow: View {
|
||||
// ist weg — bei einem erkennbaren Programmsymbol ist er redundant, und
|
||||
// die gewonnene Zeile macht die Liste halb so hoch. Wer ihn braucht,
|
||||
// hält den Zeiger auf das Symbol.
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: Onyx.Space.row) {
|
||||
icon
|
||||
|
||||
Button {
|
||||
@@ -182,7 +172,7 @@ private struct ProcessRow: View {
|
||||
mixer.setMuted(!muted, for: group.bundleID)
|
||||
} label: {
|
||||
Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol)
|
||||
.font(.system(size: 11))
|
||||
.font(.system(size: Onyx.Symbol.small))
|
||||
.frame(width: 15, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -190,8 +180,8 @@ private struct ProcessRow: View {
|
||||
: (managed ? Onyx.Color.textSecondary
|
||||
: Onyx.Color.textTertiary))
|
||||
|
||||
VolumeBar(
|
||||
volume: volume,
|
||||
OnyxSlider(
|
||||
value: volume,
|
||||
maximum: Gain.maximum(overdrive: overdriven),
|
||||
level: mixer.level(for: group.bundleID),
|
||||
isActive: managed && !muted,
|
||||
@@ -238,7 +228,7 @@ private struct ProcessRow: View {
|
||||
mixer.setOverdrive(!overdriven, for: group.bundleID)
|
||||
} label: {
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.font(.system(size: Onyx.Symbol.caption, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(overdriven ? Onyx.Color.warning : Onyx.Color.textTertiary.opacity(0.55))
|
||||
@@ -265,7 +255,7 @@ private struct ProcessRow: View {
|
||||
mixer.setVolume(1, for: group.bundleID)
|
||||
} label: {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.font(.system(size: Onyx.Symbol.caption, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
@@ -304,7 +294,7 @@ private struct ProcessRow: View {
|
||||
.opacity(managed ? 1 : 0.65)
|
||||
} else {
|
||||
Image(systemName: "app.dashed")
|
||||
.font(.system(size: 15))
|
||||
.font(.system(size: Onyx.Symbol.large))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
}
|
||||
@@ -326,95 +316,6 @@ private struct ProcessRow: View {
|
||||
return "\(group.name) (\(group.processes.count))"
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Lautstärkeregler.
|
||||
///
|
||||
/// Selbst gezeichnet statt `Slider`: der Systemregler bringt sein eigenes
|
||||
/// Erscheinungsbild mit, das auf der dunklen Fläche wie ein Fremdkörper sitzt.
|
||||
/// Und er kann nicht zeigen, was hier den Unterschied macht — den Pegel und
|
||||
/// die Grenze bei 100 %.
|
||||
private struct VolumeBar: View {
|
||||
let volume: Double
|
||||
/// Der rechte Anschlag: 1 im Normalfall, 4 mit Overdrive.
|
||||
let maximum: Double
|
||||
let level: Double
|
||||
let isActive: Bool
|
||||
let onChange: (Double) -> Void
|
||||
|
||||
@State private var isDragging = false
|
||||
|
||||
private let trackHeight: CGFloat = 6
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let width = geometry.size.width
|
||||
let fraction = min(max(volume / maximum, 0), 1)
|
||||
let knob: CGFloat = isDragging ? 13 : 11
|
||||
// Der Knopf soll an den Enden nicht überstehen.
|
||||
let travel = max(width - knob, 1)
|
||||
let fillWidth = travel * fraction + knob / 2
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Onyx.Color.surface)
|
||||
.frame(height: trackHeight)
|
||||
|
||||
// Markierung bei 100 %: darüber wird verstärkt, und man soll
|
||||
// sehen, wann man diese Grenze überschreitet. Ohne Overdrive
|
||||
// liegt sie am rechten Anschlag und sagt dort nichts mehr.
|
||||
if maximum > 1 {
|
||||
Rectangle()
|
||||
.fill(Onyx.Color.hairline)
|
||||
.frame(width: 1, height: trackHeight + 4)
|
||||
.offset(x: knob / 2 + travel / maximum)
|
||||
}
|
||||
|
||||
Capsule()
|
||||
.fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent)
|
||||
.frame(width: fillWidth, height: trackHeight)
|
||||
.opacity(isActive ? 1 : 0.3)
|
||||
|
||||
// Der Pegel läuft **im** Regler mit, nicht daneben: eine
|
||||
// zweite Leiste pro Zeile wäre doppelt so viel Grafik für
|
||||
// dieselbe Auskunft.
|
||||
if isActive, level > 0.001 {
|
||||
Capsule()
|
||||
.fill(.white.opacity(0.4))
|
||||
.frame(width: fillWidth * meterFraction, height: trackHeight)
|
||||
.blendMode(.plusLighter)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
Circle()
|
||||
.fill(.white)
|
||||
.frame(width: knob, height: knob)
|
||||
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
|
||||
.offset(x: travel * fraction)
|
||||
.opacity(isActive ? 1 : 0.55)
|
||||
}
|
||||
.frame(height: 16)
|
||||
.contentShape(.rect)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
isDragging = true
|
||||
let position = min(max((value.location.x - knob / 2) / travel, 0), 1)
|
||||
onChange(position * maximum)
|
||||
}
|
||||
.onEnded { _ in isDragging = false })
|
||||
}
|
||||
.frame(height: 16)
|
||||
.animation(Onyx.Motion.value, value: isDragging)
|
||||
}
|
||||
|
||||
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos:
|
||||
/// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
|
||||
/// Bereich so weit, dass man überhaupt etwas sieht.
|
||||
private var meterFraction: Double {
|
||||
min(1, sqrt(min(max(level, 0), 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
public struct AudioMixerWidget: OnyxWidget {
|
||||
@@ -442,24 +343,6 @@ public struct AudioMixerWidget: OnyxWidget {
|
||||
|
||||
// MARK: - Systemzeilen
|
||||
|
||||
private struct SectionTitle: View {
|
||||
let key: LocalizedStringKey
|
||||
let compact: Bool
|
||||
|
||||
init(_ key: LocalizedStringKey, compact: Bool) {
|
||||
self.key = key
|
||||
self.compact = compact
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(key, bundle: .module)
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
.textCase(.uppercase)
|
||||
.padding(.top, compact ? 0 : 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ausgabe, Eingabe und Gerätewahl.
|
||||
///
|
||||
/// Bewusst dieselbe Zeilenform wie bei den Programmen: Symbol, Stummschalter,
|
||||
@@ -471,7 +354,8 @@ private struct SystemSection: View {
|
||||
let compact: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
OnyxCard {
|
||||
VStack(spacing: 0) {
|
||||
SystemRow(
|
||||
symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||
title: outputName,
|
||||
@@ -486,7 +370,7 @@ private struct SystemSection: View {
|
||||
onMute: { system.setOutputMuted(!system.outputMuted) },
|
||||
onSelect: { system.selectOutput($0) })
|
||||
|
||||
Divider().overlay(Onyx.Color.hairline)
|
||||
OnyxHairline()
|
||||
|
||||
SystemRow(
|
||||
symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill",
|
||||
@@ -504,12 +388,7 @@ private struct SystemSection: View {
|
||||
onVolume: { system.setInputVolume($0) },
|
||||
onMute: { system.setInputMuted(!system.inputMuted) },
|
||||
onSelect: { system.selectInput($0) })
|
||||
}
|
||||
.background(Onyx.Color.elevated.opacity(0.55),
|
||||
in: .rect(cornerRadius: 10, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,14 +434,14 @@ private struct SystemRow: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: Onyx.Space.xs) {
|
||||
Text(title)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 7))
|
||||
.font(.system(size: Onyx.Symbol.caption))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -571,18 +450,18 @@ private struct SystemRow: View {
|
||||
.menuIndicator(.hidden)
|
||||
.fixedSize()
|
||||
|
||||
HStack(spacing: 9) {
|
||||
HStack(spacing: Onyx.Space.row) {
|
||||
Button(action: onMute) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 11))
|
||||
.font(.system(size: Onyx.Symbol.small))
|
||||
.frame(width: 15, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary)
|
||||
|
||||
if adjustable {
|
||||
SystemVolumeBar(volume: volume, level: level,
|
||||
isActive: !muted, onChange: onVolume)
|
||||
OnyxSlider(value: volume, level: level,
|
||||
isActive: !muted, onChange: onVolume)
|
||||
if let metering { metering }
|
||||
Text("\(Int(volume * 100)) %")
|
||||
.font(Onyx.Font.metricSmall).monospacedDigit()
|
||||
@@ -599,7 +478,7 @@ private struct SystemRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.horizontal, Onyx.Space.row)
|
||||
.padding(.vertical, compact ? 7 : 9)
|
||||
}
|
||||
}
|
||||
@@ -617,7 +496,7 @@ struct MeteringButton: View {
|
||||
var body: some View {
|
||||
Button(action: toggle) {
|
||||
Image(systemName: isOn ? "waveform.circle.fill" : "waveform.circle")
|
||||
.font(.system(size: 12))
|
||||
.font(.system(size: Onyx.Symbol.medium))
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -625,68 +504,3 @@ struct MeteringButton: View {
|
||||
.help(Text(isOn ? "mixer.input.meterOff" : "mixer.input.meterOn", bundle: .module))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie der Programmregler, nur ohne Verstärkung und ohne Pegel: die
|
||||
/// Systemlautstärke geht von null bis hundert, mehr gibt Core Audio nicht her.
|
||||
private struct SystemVolumeBar: View {
|
||||
let volume: Double
|
||||
let level: Double
|
||||
let isActive: Bool
|
||||
let onChange: (Double) -> Void
|
||||
|
||||
@State private var isDragging = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let knob: CGFloat = isDragging ? 13 : 11
|
||||
let travel = max(geometry.size.width - knob, 1)
|
||||
let fraction = min(max(volume, 0), 1)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Onyx.Color.surface).frame(height: 6)
|
||||
let fillWidth = travel * fraction + knob / 2
|
||||
|
||||
Capsule()
|
||||
.fill(Onyx.Color.accent)
|
||||
.frame(width: fillWidth, height: 6)
|
||||
.opacity(isActive ? 1 : 0.3)
|
||||
|
||||
// Derselbe mitlaufende Pegel wie in den Programmzeilen. Er
|
||||
// sitzt **im** Regler, weil eine zweite Leiste daneben doppelt
|
||||
// so viel Grafik für dieselbe Auskunft wäre.
|
||||
if isActive, level > 0.001 {
|
||||
Capsule()
|
||||
.fill(.white.opacity(0.4))
|
||||
.frame(width: fillWidth * meterFraction, height: 6)
|
||||
.blendMode(.plusLighter)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
Circle()
|
||||
.fill(.white)
|
||||
.frame(width: knob, height: knob)
|
||||
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
|
||||
.offset(x: travel * fraction)
|
||||
.opacity(isActive ? 1 : 0.55)
|
||||
}
|
||||
.frame(height: 16)
|
||||
.contentShape(.rect)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
isDragging = true
|
||||
onChange(min(max((value.location.x - knob / 2) / travel, 0), 1))
|
||||
}
|
||||
.onEnded { _ in isDragging = false })
|
||||
}
|
||||
.frame(height: 16)
|
||||
.animation(Onyx.Motion.value, value: isDragging)
|
||||
}
|
||||
|
||||
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos:
|
||||
/// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
|
||||
/// Bereich so weit, dass man überhaupt etwas sieht.
|
||||
private var meterFraction: Double {
|
||||
min(1, sqrt(min(max(level, 0), 1)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ public final class SystemAudio {
|
||||
public private(set) var inputIsAdjustable = false
|
||||
|
||||
private var listeners: [PropertyListener] = []
|
||||
/// Zuhörer, die am gewählten Gerät hängen und beim Wechsel neu gesetzt
|
||||
/// werden müssen.
|
||||
private var deviceListeners: [PropertyListener] = []
|
||||
|
||||
/// Die Pegel von Ausgabe und Eingabe.
|
||||
///
|
||||
@@ -59,6 +62,9 @@ public final class SystemAudio {
|
||||
// MARK: - Lesen
|
||||
|
||||
public func refresh() {
|
||||
let previousOutput = outputDeviceID
|
||||
let previousInput = inputDeviceID
|
||||
|
||||
outputDevices = Self.devices().filter(\.isOutput)
|
||||
inputDevices = Self.devices().filter(\.isInput)
|
||||
|
||||
@@ -79,6 +85,13 @@ public final class SystemAudio {
|
||||
alertVolume = alertDevice == outputDeviceID
|
||||
? nil // Dasselbe Gerät, derselbe Regler — keine zweite Zeile dafür.
|
||||
: Self.volume(alertDevice, scope: kAudioDevicePropertyScopeOutput)
|
||||
|
||||
// Nur bei einem echten Wechsel neu anmelden. Ein Abmelden mitten im
|
||||
// Rückruf, der gerade läuft, ist eine Einladung zum Absturz.
|
||||
if outputDeviceID != previousOutput || inputDeviceID != previousInput
|
||||
|| deviceListeners.isEmpty {
|
||||
observeCurrentDevices()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Setzen
|
||||
@@ -135,6 +148,48 @@ public final class SystemAudio {
|
||||
}
|
||||
}
|
||||
|
||||
/// Zuhörer an Lautstärke und Stummschaltung der **gerade gewählten** Geräte.
|
||||
///
|
||||
/// **Das fehlte.** Beobachtet wurden nur Gerätewechsel; wer die Lautstärke
|
||||
/// per Tastatur änderte, bekam davon nichts mit — der Regler im Mixer stand
|
||||
/// dann auf einem Wert, den es nicht mehr gab, bis irgendetwas anderes ein
|
||||
/// `refresh()` auslöste.
|
||||
///
|
||||
/// Die Zuhörer hängen am Gerät, nicht am Systemobjekt, und müssen deshalb
|
||||
/// bei jedem Gerätewechsel neu gesetzt werden.
|
||||
private func observeCurrentDevices() {
|
||||
deviceListeners = []
|
||||
for (device, scope) in [(outputDeviceID, kAudioDevicePropertyScopeOutput),
|
||||
(inputDeviceID, kAudioDevicePropertyScopeInput)] {
|
||||
guard device != AudioObjectID(kAudioObjectUnknown) else { continue }
|
||||
for selector in [kAudioDevicePropertyVolumeScalar, kAudioDevicePropertyMute] {
|
||||
// Manche Geräte führen keine Hauptlautstärke, sondern nur
|
||||
// Kanäle. Dort scheitert das Anmelden — das ist kein Fehler,
|
||||
// dort gibt es schlicht nichts zu hören.
|
||||
if let listener = PropertyListener(object: device,
|
||||
selector: selector,
|
||||
scope: scope,
|
||||
handler: { [weak self] in
|
||||
Task { @MainActor in self?.readLevels() }
|
||||
}) {
|
||||
deviceListeners.append(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nur die Pegel und Schalter neu lesen — ohne Geräteliste.
|
||||
///
|
||||
/// Getrennt von `refresh()`, weil dieser Weg aus einem Rückruf kommt, der
|
||||
/// **während** einer Änderung feuert: dort auch noch die Geräte neu
|
||||
/// aufzuzählen wäre Arbeit für nichts.
|
||||
private func readLevels() {
|
||||
outputVolume = Self.volume(outputDeviceID, scope: kAudioDevicePropertyScopeOutput) ?? 0
|
||||
outputMuted = Self.muted(outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
|
||||
inputVolume = Self.volume(inputDeviceID, scope: kAudioDevicePropertyScopeInput) ?? 0
|
||||
inputMuted = Self.muted(inputDeviceID, scope: kAudioDevicePropertyScopeInput)
|
||||
}
|
||||
|
||||
private static func devices() -> [AudioDevice] {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
@@ -325,14 +380,23 @@ public final class SystemAudio {
|
||||
/// geschrieben.
|
||||
private final class PropertyListener: @unchecked Sendable {
|
||||
|
||||
private let object = AudioObjectID(kAudioObjectSystemObject)
|
||||
private let object: AudioObjectID
|
||||
private var address: AudioObjectPropertyAddress
|
||||
private let block: AudioObjectPropertyListenerBlock
|
||||
|
||||
init?(selector: AudioObjectPropertySelector, handler: @escaping @Sendable () -> Void) {
|
||||
/// - Parameters:
|
||||
/// - object: Standardmäßig das Systemobjekt. Für Lautstärke und
|
||||
/// Stummschaltung muss es das **Gerät** sein — die sind keine
|
||||
/// Eigenschaften der Hardware im Allgemeinen, sondern dieser Kiste.
|
||||
/// - scope: Ein- oder Ausgabe. Global genügt nur bei Geräteeigenschaften.
|
||||
init?(object: AudioObjectID = AudioObjectID(kAudioObjectSystemObject),
|
||||
selector: AudioObjectPropertySelector,
|
||||
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal,
|
||||
handler: @escaping @Sendable () -> Void) {
|
||||
self.object = object
|
||||
address = AudioObjectPropertyAddress(
|
||||
mSelector: selector,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mScope: scope,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
block = { _, _ in handler() }
|
||||
guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr
|
||||
|
||||
Reference in New Issue
Block a user