Pegel auch für Ausgabe und Mikrofon
Die Programmzeilen hatten ihren mitlaufenden Ausschlag, die beiden Systemregler nicht. Jetzt beide, in derselben Form: der Pegel läuft im Regler mit, nicht daneben. Der Ausgangspegel kommt über einen globalen Tap — denselben Weg wie der Mixer, nur ohne Stummschalten und ohne Ausgabe. Ausgenommen werden die Programme, die der Mixer bereits abgreift: ein Tap sieht das Signal vor dem Stummschalten, sie zählten sonst doppelt — einmal als Original, das niemand hört, und einmal als das, was Onyx daraus macht. Ändert sich diese Liste, wird der Tap neu aufgebaut. Der Mikrofonpegel geht nur mit echter Aufnahme. Es gibt keine Eigenschaft am Gerät, die den Ausschlag verrät — einen Pegel bekommt nur, wer das Signal liest, und genau das meldet macOS mit dem orangen Punkt. Das ist richtig so, also läuft die Messung nur, solange die Ansicht offen ist, und hört beim Schließen sofort auf. Ein Zähler statt eines Schalters, weil Panel und Popover gleichzeitig offen sein können. Geräte, die keine Fließkommawerte liefern, bleiben ohne Pegel: als Fließkomma gelesene Ganzzahlen ergeben keinen falschen Ausschlag, sondern Unsinn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,8 @@
|
||||
<string>Onyx zeigt das Wetter an deinem aktuellen Standort. Du kannst stattdessen auch einen festen Ort wählen.</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Onyx steuert Spotify und Musik, wenn die universelle Medienschnittstelle nicht verfügbar ist.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Onyx zeigt im Mixer an, wie stark dein Mikrofon gerade ausschlägt. Gemessen wird nur, solange die Ansicht offen ist — aufgezeichnet wird nichts.</string>
|
||||
<key>NSAudioCaptureUsageDescription</key>
|
||||
<string>Onyx braucht Zugriff auf die Audioausgabe, um die Lautstärke einzelner Programme zu regeln. Es wird nichts aufgezeichnet.</string>
|
||||
|
||||
|
||||
@@ -105,6 +105,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
menuBar.register(NetworkMenuBarModule(model: networkModel),
|
||||
settings: model.menuBarSettings(for: "network"))
|
||||
}
|
||||
// Der Ausgangspegel muss die Programme auslassen, die der Mixer bereits
|
||||
// abgreift — nur der Mixer weiß, welche das sind.
|
||||
systemAudio.levels.mutedProcesses = { [weak audioMixer] in
|
||||
audioMixer?.tappedProcessIDs ?? []
|
||||
}
|
||||
|
||||
// Mixer und Lüfter gehören genauso in die Leiste wie die Messwerte:
|
||||
// beides regelt man mitten in etwas anderem, und dafür ist der Weg
|
||||
// über ein Fenster zu weit.
|
||||
|
||||
@@ -245,6 +245,15 @@ public final class AudioMixer {
|
||||
|
||||
public func isManaged(_ bundleID: String) -> Bool { managed.contains(bundleID) }
|
||||
|
||||
/// Die Prozesse, deren Originalton gerade stummgeschaltet ist.
|
||||
///
|
||||
/// Der Ausgangspegel muss sie auslassen: ein Tap sieht das Signal **vor**
|
||||
/// dem Stummschalten, sie zählten sonst doppelt — einmal als Original, das
|
||||
/// niemand hört, und einmal als das, was Onyx daraus macht.
|
||||
public var tappedProcessIDs: [AudioObjectID] {
|
||||
taps.values.map(\.process.objectID)
|
||||
}
|
||||
|
||||
/// Der Ausschlag eines Programms, 0…1 — für die Pegelanzeige.
|
||||
public func level(for bundleID: String) -> Double { levels[bundleID] ?? 0 }
|
||||
|
||||
|
||||
@@ -86,6 +86,11 @@ public struct MixerView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.animation(Onyx.Motion.value, value: mixer.groups.count)
|
||||
// Die Pegel laufen nur, solange die Ansicht sichtbar ist. Der
|
||||
// Mikrofonpegel schaltet dabei den orangen Punkt an — ein Messgerät,
|
||||
// das im Hintergrund weiterläuft, wäre hier das Falsche.
|
||||
.onAppear { system.levels.addConsumer() }
|
||||
.onDisappear { system.levels.removeConsumer() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,6 +468,7 @@ private struct SystemSection: View {
|
||||
devices: system.outputDevices,
|
||||
selected: system.outputDeviceID,
|
||||
volume: system.outputVolume,
|
||||
level: system.levels.output,
|
||||
muted: system.outputMuted,
|
||||
adjustable: system.outputIsAdjustable,
|
||||
compact: compact,
|
||||
@@ -478,6 +484,7 @@ private struct SystemSection: View {
|
||||
devices: system.inputDevices,
|
||||
selected: system.inputDeviceID,
|
||||
volume: system.inputVolume,
|
||||
level: system.levels.input,
|
||||
muted: system.inputMuted,
|
||||
adjustable: system.inputIsAdjustable,
|
||||
compact: compact,
|
||||
@@ -510,6 +517,7 @@ private struct SystemRow: View {
|
||||
let devices: [AudioDevice]
|
||||
let selected: AudioObjectID
|
||||
let volume: Double
|
||||
let level: Double
|
||||
let muted: Bool
|
||||
let adjustable: Bool
|
||||
let compact: Bool
|
||||
@@ -558,7 +566,8 @@ private struct SystemRow: View {
|
||||
.foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary)
|
||||
|
||||
if adjustable {
|
||||
SystemVolumeBar(volume: volume, isActive: !muted, onChange: onVolume)
|
||||
SystemVolumeBar(volume: volume, level: level,
|
||||
isActive: !muted, onChange: onVolume)
|
||||
Text("\(Int(volume * 100)) %")
|
||||
.font(Onyx.Font.metricSmall).monospacedDigit()
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
@@ -583,6 +592,7 @@ private struct SystemRow: View {
|
||||
/// 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
|
||||
|
||||
@@ -596,10 +606,24 @@ private struct SystemVolumeBar: View {
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Onyx.Color.surface).frame(height: 6)
|
||||
let fillWidth = travel * fraction + knob / 2
|
||||
|
||||
Capsule()
|
||||
.fill(Onyx.Color.accent)
|
||||
.frame(width: travel * fraction + knob / 2, height: 6)
|
||||
.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)
|
||||
@@ -620,4 +644,11 @@ private struct SystemVolumeBar: View {
|
||||
.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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ public final class SystemAudio {
|
||||
|
||||
private var listeners: [PropertyListener] = []
|
||||
|
||||
/// Die Pegel von Ausgabe und Eingabe.
|
||||
///
|
||||
/// Hier und nicht im Mixer: sie gehören zu den Systemreglern, und die
|
||||
/// stehen bewusst außerhalb des Mixer-Schalters.
|
||||
public let levels = SystemLevels()
|
||||
|
||||
public init() {
|
||||
refresh()
|
||||
observeSystemChanges()
|
||||
|
||||
329
Packages/OnyxKit/Sources/AudioProvider/SystemLevels.swift
Normal file
329
Packages/OnyxKit/Sources/AudioProvider/SystemLevels.swift
Normal file
@@ -0,0 +1,329 @@
|
||||
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 — für den Hinweis in der
|
||||
/// Oberfläche. Wer den orangen Punkt sieht, soll in Onyx nachlesen können,
|
||||
/// warum er da ist.
|
||||
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 }
|
||||
rebuild()
|
||||
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()
|
||||
inputMeter.stop()
|
||||
isListeningToInput = false
|
||||
output = 0
|
||||
input = 0
|
||||
}
|
||||
|
||||
private func rebuild() {
|
||||
if let uid = Self.defaultDeviceUID(kAudioHardwarePropertyDefaultOutputDevice) {
|
||||
outputMeter.start(excluding: mutedProcesses(), outputDeviceUID: uid)
|
||||
}
|
||||
inputMeter.start(device: Self.defaultDevice(kAudioHardwarePropertyDefaultInputDevice))
|
||||
isListeningToInput = inputMeter.isRunning
|
||||
}
|
||||
|
||||
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 = decay(input, toward: Double(inputMeter.peak.take()))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user