diff --git a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings index 95266e3..45283c8 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings @@ -52,6 +52,38 @@ } } }, + "mixer.input.meterOff": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mikrofonpegel ausschalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop showing the microphone level" + } + } + } + }, + "mixer.input.meterOn": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mikrofonpegel anzeigen — bei Bluetooth-Hörern schaltet macOS dabei auf Telefonqualität um" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show microphone level — with Bluetooth headsets macOS switches to call quality" + } + } + } + }, "mixer.name": { "localizations": { "de": { diff --git a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift index ee0ee48..f318f8d 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift @@ -495,6 +495,9 @@ private struct SystemSection: View { selected: system.inputDeviceID, volume: system.inputVolume, level: system.levels.input, + // Der Mikrofonpegel nur auf Knopfdruck — siehe SystemLevels. + metering: MeteringButton(isOn: system.levels.isListeningToInput, + toggle: { system.levels.toggleInputMetering() }), muted: system.inputMuted, adjustable: system.inputIsAdjustable, compact: compact, @@ -528,6 +531,8 @@ private struct SystemRow: View { let selected: AudioObjectID let volume: Double let level: Double + /// Nur die Eingabezeile hat einen — die Ausgabe misst ohne Nebenwirkung. + var metering: MeteringButton? let muted: Bool let adjustable: Bool let compact: Bool @@ -578,6 +583,7 @@ private struct SystemRow: View { if adjustable { SystemVolumeBar(volume: volume, level: level, isActive: !muted, onChange: onVolume) + if let metering { metering } Text("\(Int(volume * 100)) %") .font(Onyx.Font.metricSmall).monospacedDigit() .foregroundStyle(Onyx.Color.textTertiary) @@ -598,6 +604,28 @@ private struct SystemRow: View { } } +/// Der Knopf, der den Mikrofonpegel anschaltet. +/// +/// **Ein Knopf und kein Automatismus**, weil ein offener Eingangsstrom bei +/// Bluetooth-Hörern nicht folgenlos ist: macOS schaltet sie ins Telefonprofil, +/// die Abtastrate halbiert sich und die Wiedergabe wird hörbar lauter. Wer nur +/// die Lautstärke regeln will, hat das nicht bestellt. +struct MeteringButton: View { + let isOn: Bool + let toggle: () -> Void + + var body: some View { + Button(action: toggle) { + Image(systemName: isOn ? "waveform.circle.fill" : "waveform.circle") + .font(.system(size: 12)) + .frame(width: 14, height: 14) + } + .buttonStyle(.plain) + .foregroundStyle(isOn ? Onyx.Color.accent : Onyx.Color.textTertiary) + .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 { diff --git a/Packages/OnyxKit/Sources/AudioProvider/SystemLevels.swift b/Packages/OnyxKit/Sources/AudioProvider/SystemLevels.swift index 850d06c..a49a8be 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/SystemLevels.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/SystemLevels.swift @@ -224,9 +224,14 @@ public final class SystemLevels { public private(set) var output: Double = 0 public private(set) var input: Double = 0 - /// Ob gerade tatsächlich das Mikrofon gelesen wird — für den Hinweis in der - /// Oberfläche. Wer den orangen Punkt sieht, soll in Onyx nachlesen können, - /// warum er da ist. + /// Ob gerade tatsächlich das Mikrofon gelesen wird. + /// + /// **Nur auf Knopfdruck.** Ein Eingangsstrom ist bei Bluetooth-Hörern kein + /// stiller Mitleser: macOS schaltet sie dabei ins Telefonprofil. Gemessen + /// an AirPods Pro fällt die Abtastrate von 48 auf 24 kHz, und der + /// Lautstärkeregler des Geräts springt von 0,0 auf 0,6 — die Musik wird + /// hörbar lauter und schlechter. Das darf nicht passieren, weil jemand ein + /// Lautstärkefenster aufmacht. public private(set) var isListeningToInput = false /// Prozesse, deren Originalton der Mixer bereits stummgeschaltet hat. @@ -248,7 +253,7 @@ public final class SystemLevels { public func addConsumer() { consumers += 1 guard consumers == 1 else { return } - rebuild() + startOutputMeter() let timer = Timer(timeInterval: 1.0 / 15, repeats: true) { [weak self] _ in Task { @MainActor in self?.sample() } } @@ -262,18 +267,33 @@ public final class SystemLevels { timer?.invalidate() timer = nil outputMeter.stop() - inputMeter.stop() - isListeningToInput = false + // Das Mikrofon geht in jedem Fall mit aus — auch wenn es noch + // eingeschaltet war. Es soll nie länger offen sein als die Ansicht. + stopInputMetering() output = 0 input = 0 } - private func rebuild() { - if let uid = Self.defaultDeviceUID(kAudioHardwarePropertyDefaultOutputDevice) { - outputMeter.start(excluding: mutedProcesses(), outputDeviceUID: uid) + /// Schaltet die Mikrofonmessung an oder aus. + public func toggleInputMetering() { + if isListeningToInput { + stopInputMetering() + } else { + inputMeter.start(device: Self.defaultDevice(kAudioHardwarePropertyDefaultInputDevice)) + isListeningToInput = inputMeter.isRunning } - inputMeter.start(device: Self.defaultDevice(kAudioHardwarePropertyDefaultInputDevice)) - isListeningToInput = inputMeter.isRunning + } + + private func stopInputMetering() { + inputMeter.stop() + isListeningToInput = false + input = 0 + } + + private func startOutputMeter() { + guard let uid = Self.defaultDeviceUID(kAudioHardwarePropertyDefaultOutputDevice) + else { return } + outputMeter.start(excluding: mutedProcesses(), outputDeviceUID: uid) } private func sample() { @@ -290,7 +310,7 @@ public final class SystemLevels { // Schnell hoch, langsam runter — wie bei den Programmzeilen, damit // beide Balken dieselbe Sprache sprechen. output = decay(output, toward: Double(outputMeter.peak.take())) - input = decay(input, toward: Double(inputMeter.peak.take())) + input = isListeningToInput ? decay(input, toward: Double(inputMeter.peak.take())) : 0 } private func decay(_ current: Double, toward peak: Double) -> Double {