Files
onyx/Packages/OnyxKit/Sources/AudioProvider/SystemLevels.swift
Scarriffle 286d886e9e Das Mikrofon nur noch auf Knopfdruck
Ein offener Eingangsstrom ist bei Bluetooth-Hörern kein stiller Mitleser.
Gemessen an AirPods Pro:

  vorher    48000 Hz, Gerätelautstärke 0.0
  mit Mikro 24000 Hz, Gerätelautstärke 0.6
  danach    48000 Hz, Gerätelautstärke 0.0

macOS schaltet sie ins Telefonprofil: halbe Abtastrate, und der
Lautstärkeregler des Geräts springt. Die Musik wird hörbar lauter und
schlechter — beim Öffnen des Popovers, und beim Schließen wieder zurück.
Dasselbe passiert, wenn am Telefon eine App das Mikrofon greift.

Das darf nicht passieren, weil jemand ein Lautstärkefenster aufmacht. Der
Mikrofonpegel hängt deshalb an einem Knopf in der Eingabezeile, mit dem
Hinweis im Tooltip, was er auslöst. Beim Schließen des Popovers geht er in
jedem Fall wieder aus.

Der Ausgangspegel bleibt automatisch: der läuft über einen Process Tap und
fasst das Gerät nicht an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:32:19 +02:00

350 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Synchronization
import AudioToolbox
import CoreAudio
import AVFoundation
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio")
/// Der höchste Ausschlag seit dem letzten Abholen als Bitmuster, weil
/// `Atomic` keine Fließkommazahlen führt.
///
/// Sperrenfrei, weil geschrieben wird, während der Audiothread läuft. Eine
/// gewöhnliche Eigenschaft wäre dort ein Wettlauf, eine Sperre ein Aussetzer.
final class PeakMeter: Sendable {
private let bits = Atomic<UInt32>(0)
func record(_ magnitude: Float) {
let previous = Float(bitPattern: bits.load(ordering: .relaxed))
if magnitude > previous { bits.store(magnitude.bitPattern, ordering: .relaxed) }
}
/// Holt den Höchstwert ab und setzt zurück, damit die Anzeige abfällt,
/// statt auf der Spitze stehen zu bleiben.
func take() -> Float { Float(bitPattern: bits.exchange(0, ordering: .relaxed)) }
}
/// Misst, was gerade aus dem Lautsprecher kommt.
///
/// Über einen globalen Tap, also denselben Weg wie der Mixer nur ohne
/// Stummschalten und ohne Ausgabe. Ausgenommen werden die Programme, die der
/// Mixer bereits abgreift: deren Originalton ist stummgeschaltet, aber ein Tap
/// sieht ihn trotzdem. Sie zählten sonst doppelt einmal als Original, das
/// niemand hört, und einmal als das, was Onyx daraus macht.
final class OutputMeter {
let peak = PeakMeter()
private(set) var excluded: [AudioObjectID] = []
private var tapID = AudioObjectID(kAudioObjectUnknown)
private var aggregateID = AudioObjectID(kAudioObjectUnknown)
private var procID: AudioDeviceIOProcID?
var isRunning: Bool { procID != nil }
deinit { stop() }
func start(excluding processes: [AudioObjectID], outputDeviceUID: String) {
stop()
excluded = processes
let tapUUID = UUID()
let description = CATapDescription(stereoGlobalTapButExcludeProcesses: processes)
description.uuid = tapUUID
description.name = "Onyx Ausgangspegel"
description.isPrivate = true
// Nur zuhören. Ein Pegelmesser, der die Wiedergabe verändert, wäre
// kein Messgerät.
description.muteBehavior = .unmuted
guard AudioHardwareCreateProcessTap(description, &tapID) == noErr else {
tapID = AudioObjectID(kAudioObjectUnknown)
return
}
let aggregate: [String: Any] = [
kAudioAggregateDeviceNameKey: "Onyx Pegel",
kAudioAggregateDeviceUIDKey: ProcessTap.aggregateUIDPrefix + UUID().uuidString,
kAudioAggregateDeviceIsPrivateKey: true,
kAudioAggregateDeviceIsStackedKey: false,
kAudioAggregateDeviceMainSubDeviceKey: outputDeviceUID,
kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputDeviceUID]],
kAudioAggregateDeviceTapListKey: [[
kAudioSubTapUIDKey: tapUUID.uuidString,
kAudioSubTapDriftCompensationKey: true,
]],
]
guard AudioHardwareCreateAggregateDevice(aggregate as CFDictionary, &aggregateID) == noErr
else {
aggregateID = AudioObjectID(kAudioObjectUnknown)
stop()
return
}
var proc: AudioDeviceIOProcID?
let status = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) {
[peak] _, inputData, _, outputData, _ in
let input = UnsafeMutableAudioBufferListPointer(
UnsafeMutablePointer(mutating: inputData))
for buffer in input {
guard let data = buffer.mData else { continue }
let count = Int(buffer.mDataByteSize) / MemoryLayout<Float>.size
let samples = data.assumingMemoryBound(to: Float.self)
var high: Float = 0
for index in 0..<count {
let magnitude = abs(samples[index])
if magnitude > high { high = magnitude }
}
peak.record(high)
}
// Die eigene Ausgabe bleibt still: das Gerät gibt auf denselben
// Lautsprecher aus, und ungeschriebene Puffer enthalten, was zuletzt
// darin stand.
AudioBuffers.silence(outputData)
}
guard status == noErr, let proc else { stop(); return }
procID = proc
guard AudioDeviceStart(aggregateID, proc) == noErr else { stop(); return }
}
func stop() {
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)
}
}
}
/// Misst, was das Mikrofon hört.
///
/// **Dafür wird tatsächlich aufgenommen** anders geht es nicht. Es gibt keine
/// Eigenschaft am Gerät, die den Ausschlag verrät; einen Pegel bekommt nur, wer
/// das Signal liest. Genau das meldet macOS mit dem orangen Punkt, und das ist
/// richtig so. Deshalb läuft diese Messung nur, solange jemand hinschaut, und
/// hört mit dem Schließen des Popovers sofort wieder auf.
final class InputMeter {
let peak = PeakMeter()
private var deviceID = AudioObjectID(kAudioObjectUnknown)
private var procID: AudioDeviceIOProcID?
var isRunning: Bool { procID != nil }
deinit { stop() }
func start(device: AudioObjectID) {
stop()
guard device != kAudioObjectUnknown, Self.deliversFloat(device) else { return }
deviceID = device
var proc: AudioDeviceIOProcID?
let status = AudioDeviceCreateIOProcIDWithBlock(&proc, device, nil) {
[peak] _, inputData, _, outputData, _ in
let input = UnsafeMutableAudioBufferListPointer(
UnsafeMutablePointer(mutating: inputData))
for buffer in input {
guard let data = buffer.mData else { continue }
let count = Int(buffer.mDataByteSize) / MemoryLayout<Float>.size
let samples = data.assumingMemoryBound(to: Float.self)
var high: Float = 0
for index in 0..<count {
let magnitude = abs(samples[index])
if magnitude > high { high = magnitude }
}
peak.record(high)
}
// Manche Geräte haben Ein- und Ausgang. Der eigene Ausgabepuffer
// bleibt still er trägt zum Gerät nur bei, was hier hineingeht.
AudioBuffers.silence(outputData)
}
guard status == noErr, let proc else { stop(); return }
procID = proc
if AudioDeviceStart(device, proc) != noErr {
log.error("Mikrofonpegel: Gerät startet nicht")
stop()
}
}
func stop() {
if let procID {
AudioDeviceStop(deviceID, procID)
AudioDeviceDestroyIOProcID(deviceID, procID)
self.procID = nil
}
deviceID = AudioObjectID(kAudioObjectUnknown)
}
/// Liefert das Gerät Fließkommawerte?
///
/// Ohne diese Prüfung würden Ganzzahlwerte als Fließkomma gelesen das
/// ergibt keinen zu hohen oder zu niedrigen Pegel, sondern Unsinn.
private static func deliversFloat(_ device: AudioObjectID) -> Bool {
var address = AudioObjectPropertyAddress(
mSelector: kAudioStreamPropertyVirtualFormat,
mScope: kAudioObjectPropertyScopeInput,
mElement: kAudioObjectPropertyElementMain)
var format = AudioStreamBasicDescription()
var size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &format) == noErr
else { return false }
return format.mFormatFlags & kAudioFormatFlagIsFloat != 0
}
}
enum AudioBuffers {
/// Füllt alle Puffer mit Nullen.
static func silence(_ list: UnsafeMutablePointer<AudioBufferList>) {
for buffer in UnsafeMutableAudioBufferListPointer(list) {
guard let data = buffer.mData else { continue }
memset(data, 0, Int(buffer.mDataByteSize))
}
}
}
/// Die Pegel von Ausgabe und Eingabe.
///
/// Sie laufen nur, solange jemand hinschaut: beide brauchen einen offenen
/// Audioweg, der Mikrofonpegel obendrein den orangen Punkt. Ein Messgerät, das
/// im Hintergrund weiterläuft, wäre bei beidem falsch.
@MainActor
@Observable
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.
///
/// **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.
///
/// Wird von außen gesetzt, weil nur der Mixer sie kennt. Ohne die Angabe
/// zählte jedes geregelte Programm doppelt.
public var mutedProcesses: @MainActor () -> [AudioObjectID] = { [] }
@ObservationIgnored private let outputMeter = OutputMeter()
@ObservationIgnored private let inputMeter = InputMeter()
@ObservationIgnored private var consumers = 0
@ObservationIgnored private var timer: Timer?
public init() {}
/// Ein Zähler statt eines Schalters: Popover und Panel können gleichzeitig
/// offen sein, und das zweite Schließen darf nicht abschalten, was das erste
/// noch braucht.
public func addConsumer() {
consumers += 1
guard consumers == 1 else { return }
startOutputMeter()
let timer = Timer(timeInterval: 1.0 / 15, repeats: true) { [weak self] _ in
Task { @MainActor in self?.sample() }
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
public func removeConsumer() {
consumers = max(0, consumers - 1)
guard consumers == 0 else { return }
timer?.invalidate()
timer = nil
outputMeter.stop()
// 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
}
/// Schaltet die Mikrofonmessung an oder aus.
public func toggleInputMetering() {
if isListeningToInput {
stopInputMetering()
} else {
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() {
// Ändert sich, welche Programme der Mixer abgreift, muss der Tap neu
// aufgebaut werden sonst zählte ein gerade erst geregeltes Programm
// ab dem nächsten Puffer doppelt.
let muted = mutedProcesses()
if outputMeter.isRunning, muted.sorted() != outputMeter.excluded.sorted() {
if let uid = Self.defaultDeviceUID(kAudioHardwarePropertyDefaultOutputDevice) {
outputMeter.start(excluding: muted, outputDeviceUID: uid)
}
}
// Schnell hoch, langsam runter wie bei den Programmzeilen, damit
// beide Balken dieselbe Sprache sprechen.
output = decay(output, toward: Double(outputMeter.peak.take()))
input = isListeningToInput ? decay(input, toward: Double(inputMeter.peak.take())) : 0
}
private func decay(_ current: Double, toward peak: Double) -> Double {
peak > current ? peak : current * 0.72
}
// MARK: - Core Audio
private static func defaultDevice(_ selector: AudioObjectPropertySelector) -> AudioObjectID {
var address = AudioObjectPropertyAddress(
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var device = AudioObjectID(kAudioObjectUnknown)
var size = UInt32(MemoryLayout<AudioObjectID>.size)
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),
&address, 0, nil, &size, &device) == noErr
else { return AudioObjectID(kAudioObjectUnknown) }
return device
}
private static func defaultDeviceUID(_ selector: AudioObjectPropertySelector) -> String? {
let device = defaultDevice(selector)
guard device != kAudioObjectUnknown else { return nil }
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceUID,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var result: CFString?
var size = UInt32(MemoryLayout<CFString?>.size)
let status = withUnsafeMutablePointer(to: &result) {
AudioObjectGetPropertyData(device, &address, 0, nil, &size, $0)
}
guard status == noErr, let result else { return nil }
return result as String
}
}