-
-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathAudioEngine.swift
More file actions
712 lines (593 loc) · 27.2 KB
/
Copy pathAudioEngine.swift
File metadata and controls
712 lines (593 loc) · 27.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
// AudioEngine.swift
// VocaMac
//
// Real-time microphone audio capture using AVAudioEngine.
// Captures audio in the format required by whisper.cpp (16kHz, mono, Float32 PCM).
import Foundation
import AVFoundation
import AudioToolbox
import CoreAudio
import VocaMacObjC
final class AudioEngine {
// MARK: - Properties
/// AVAudioEngine is created lazily when recording starts and torn down when
/// recording stops. Keeping it alive while idle holds an input route on the
/// system mic, which on Bluetooth devices like AirPods forces the headset
/// (HFP/SCO) profile and breaks remote media controls (e.g. tap-to-pause)
/// for any other app playing audio.
private var engine: AVAudioEngine?
private var audioBuffer: [Float] = []
private var _isCurrentlyRecording = false
private let bufferQueue = DispatchQueue(label: "com.vocamac.audio-buffer", qos: .userInteractive)
private let lifecycleQueue = DispatchQueue(label: "com.vocamac.audio-engine.lifecycle", qos: .userInitiated)
static let startupConfigurationChangeRecoveryWindow: TimeInterval = 1.0
var isCurrentlyRecording: Bool {
lifecycleQueue.sync { _isCurrentlyRecording }
}
// Silence detection
private var lastSoundTime: Date = Date()
private var silenceThreshold: Float = 0.01
private var silenceDuration: Double = 2.0
private var maxDuration: TimeInterval = 60.0
private var recordingStartTime: Date = Date()
// Audio level throttling
private var lastLevelReportTime: Date = Date()
private let levelReportInterval: TimeInterval = 1.0 / 15.0 // ~15 Hz
/// Target audio format for whisper.cpp
static let whisperFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 16000.0,
channels: 1,
interleaved: false
)!
// MARK: - Callbacks
/// Called with the current audio level (0.0 - 1.0) for UI visualization
var onAudioLevel: ((Float) -> Void)?
/// Called when silence is detected for the configured duration
var onSilenceDetected: (() -> Void)?
/// Called when max recording duration is reached
var onMaxDurationReached: (() -> Void)?
/// Called when the audio device configuration changes (e.g., mic unplugged/replugged).
/// The engine is automatically stopped and reset when this happens.
/// AppState should use this to recover from a stuck recording state.
var onAudioDeviceChanged: (() -> Void)?
// MARK: - Initialization
init() {
// Note: we intentionally do NOT create the AVAudioEngine here, nor
// register for AVAudioEngineConfigurationChange. Both actions cause the
// engine's input node to materialise and claim the system input route,
// which on Bluetooth headsets forces the HFP profile. The observer is
// attached as part of `acquireEngine()` instead, and torn down by
// `releaseEngine()` when recording stops.
}
deinit {
// Make sure any active engine and its observer are released. This is a
// safety net — under normal flows `stopRecording`/`forceReset` will
// already have torn things down.
if let engine {
NotificationCenter.default.removeObserver(self, name: .AVAudioEngineConfigurationChange, object: engine)
}
}
// MARK: - Engine Lifecycle
/// Lazily create the AVAudioEngine and start observing configuration changes.
/// Must be called on `lifecycleQueue`.
private func acquireEngine() -> AVAudioEngine {
if let engine { return engine }
let newEngine = AVAudioEngine()
engine = newEngine
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioConfigurationChange),
name: .AVAudioEngineConfigurationChange,
object: newEngine
)
VocaLogger.debug(.audioEngine, "AVAudioEngine instance acquired")
return newEngine
}
/// Tear down the AVAudioEngine, removing its observer and releasing the
/// underlying input route so other apps (and Bluetooth audio profiles)
/// aren't affected while we're idle.
/// Must be called on `lifecycleQueue`.
private func releaseEngine() {
guard let engine else { return }
NotificationCenter.default.removeObserver(self, name: .AVAudioEngineConfigurationChange, object: engine)
self.engine = nil
VocaLogger.debug(.audioEngine, "AVAudioEngine instance released")
}
// MARK: - Audio Configuration Change
/// Called when macOS detects an audio hardware configuration change.
/// This happens when a microphone is unplugged/replugged, Bluetooth audio
/// disconnects, or the default audio device changes (e.g., after sleep).
///
/// When this fires during an active recording, the engine's internal state
/// is invalidated — the installed tap references a stale format and no audio
/// flows. We must stop, reset, and notify AppState so it can recover.
@objc private func handleAudioConfigurationChange(_ notification: Notification) {
lifecycleQueue.async { [weak self] in
guard let self = self else { return }
VocaLogger.info(.audioEngine, "Audio configuration changed (device plug/unplug or route change)")
let wasRecording = self._isCurrentlyRecording
let elapsedSinceRecordingStart = Date().timeIntervalSince(self.recordingStartTime)
if wasRecording,
self.engine?.isRunning == true,
Self.shouldTreatAsStartupConfigurationChange(elapsedSinceRecordingStart: elapsedSinceRecordingStart) {
VocaLogger.info(.audioEngine, "Ignoring startup audio configuration change because the engine is still running")
return
}
if wasRecording {
VocaLogger.warning(.audioEngine, "Configuration changed while recording — forcing stop and reset")
// Tear down the stale recording state
self._isCurrentlyRecording = false
self.silenceCallbackFired = false
self.maxDurationCallbackFired = false
self.removeInputTap(reason: "audio configuration change")
self.engine?.stop()
}
// Drop the engine entirely so the next recording starts from a
// clean instance bound to the new default device.
self.releaseEngine()
VocaLogger.info(.audioEngine, "Audio engine released after configuration change")
if wasRecording {
// Notify AppState on the main queue so it can handle the interrupted recording
DispatchQueue.main.async { [weak self] in
self?.onAudioDeviceChanged?()
}
}
}
}
// MARK: - Permission Handling
/// Check current microphone permission status (tri-state)
func checkPermissionStatus() -> PermissionStatus {
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
return .granted
case .notDetermined:
return .notDetermined
case .denied, .restricted:
return .denied
@unknown default:
return .denied
}
}
/// Request microphone permission from the user
func requestPermission(completion: @escaping (Bool) -> Void) {
AVCaptureDevice.requestAccess(for: .audio) { granted in
DispatchQueue.main.async {
completion(granted)
}
}
}
// MARK: - Recording Control
/// Start recording audio from the microphone
/// - Parameters:
/// - silenceThreshold: RMS energy threshold below which audio is considered silence
/// - silenceDuration: Seconds of silence before triggering silence detection callback
/// - maxDuration: Maximum recording duration in seconds
/// - Returns: `true` when the engine is recording, otherwise `false`.
@discardableResult
func startRecording(
silenceThreshold: Float = 0.01,
silenceDuration: Double = 2.0,
maxDuration: TimeInterval = 60.0,
preferredInputDeviceID: String? = nil
) -> Bool {
lifecycleQueue.sync {
guard !self._isCurrentlyRecording else { return true }
self.silenceThreshold = silenceThreshold
self.silenceDuration = silenceDuration
self.maxDuration = maxDuration
resetRecordingState()
_isCurrentlyRecording = true
let engine = acquireEngine()
let inputNode = engine.inputNode
configurePreferredInputDevice(preferredInputDeviceID, on: inputNode)
let inputFormat = inputNode.outputFormat(forBus: 0)
guard isValidInputFormat(inputFormat) else {
VocaLogger.error(
.audioEngine,
"Invalid input format before recording start: sampleRate=\(inputFormat.sampleRate), channels=\(inputFormat.channelCount)"
)
recoverFromStartFailure(notifyAppState: true)
return false
}
// A previous failed start can leave a tap installed even when our
// recording flag is false. Remove any stale tap before installing a
// fresh one; otherwise AVAudioEngine raises an uncaught NSException.
removeInputTap(reason: "pre-start cleanup")
var startError: Error?
let exception = VocaObjCExceptionCatcher.catchException { [weak self] in
guard let self = self else { return }
inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
self?.processAudioBuffer(buffer, inputFormat: inputFormat)
}
do {
try engine.start()
} catch {
startError = error
}
}
if let exception {
VocaLogger.error(.audioEngine, "AVAudioEngine exception while starting recording: \(exception.localizedDescription)")
recoverFromStartFailure(notifyAppState: true)
return false
}
if let startError {
VocaLogger.error(.audioEngine, "Failed to start audio engine: \(startError.localizedDescription)")
recoverFromStartFailure(notifyAppState: true)
return false
}
return true
}
}
/// Stop recording and return the captured audio samples
/// - Returns: Array of Float32 PCM samples at 16kHz mono
func stopRecording() -> [Float] {
lifecycleQueue.sync {
guard _isCurrentlyRecording else { return [] }
_isCurrentlyRecording = false
removeInputTap(reason: "stop recording")
engine?.stop()
let samples = capturedSamplesAndResetBuffer()
// Release the engine so we don't keep holding the system input
// route (and forcing AirPods into HFP) while idle.
releaseEngine()
return samples
}
}
/// Forcibly reset the audio engine to a clean state, regardless of current state.
/// This is a last-resort recovery mechanism — it unconditionally tears down
/// taps, stops the engine, clears buffers, and resets all flags.
/// Use when the engine is suspected to be in an inconsistent state.
func forceReset() {
lifecycleQueue.sync {
VocaLogger.warning(.audioEngine, "Force reset requested (wasRecording=\(_isCurrentlyRecording))")
_isCurrentlyRecording = false
silenceCallbackFired = false
maxDurationCallbackFired = false
removeInputTap(reason: "force reset")
engine?.stop()
engine?.reset()
clearAudioBuffer()
// Drop the engine entirely so the input route is released. The
// next recording will create a fresh instance.
releaseEngine()
VocaLogger.info(.audioEngine, "Force reset complete — engine is clean")
}
}
// MARK: - Lifecycle Helpers
/// Resets per-recording state before a new capture attempt.
private func resetRecordingState() {
clearAudioBuffer()
lastSoundTime = Date()
recordingStartTime = Date()
silenceCallbackFired = false
maxDurationCallbackFired = false
}
/// Clears captured audio samples while preserving buffer capacity.
private func clearAudioBuffer() {
bufferQueue.sync {
audioBuffer.removeAll(keepingCapacity: true)
}
}
/// Returns captured samples and clears the backing buffer.
private func capturedSamplesAndResetBuffer() -> [Float] {
bufferQueue.sync {
let copy = audioBuffer
audioBuffer.removeAll(keepingCapacity: true)
return copy
}
}
/// Checks whether a hardware input format is safe to pass to AVAudioEngine.
/// Invalid or transient formats can cause installTap to raise NSException.
private func isValidInputFormat(_ format: AVAudioFormat) -> Bool {
format.sampleRate.isFinite && format.sampleRate > 0 && format.channelCount > 0
}
/// Removes the current input tap while converting AVFoundation NSExceptions
/// into log messages instead of process aborts. No-op if the engine has
/// already been released.
private func removeInputTap(reason: String) {
guard let engine else { return }
let exception = VocaObjCExceptionCatcher.catchException {
engine.inputNode.removeTap(onBus: 0)
}
if let exception {
VocaLogger.warning(.audioEngine, "Ignoring AVAudioEngine exception while removing tap during \(reason): \(exception.localizedDescription)")
}
}
/// Restores AudioEngine to a clean idle state after any failed start attempt.
private func recoverFromStartFailure(notifyAppState: Bool) {
_isCurrentlyRecording = false
silenceCallbackFired = false
maxDurationCallbackFired = false
removeInputTap(reason: "start failure")
engine?.stop()
engine?.reset()
clearAudioBuffer()
// Release the engine so a failed start doesn't leave us holding the
// system input route (and forcing AirPods into HFP) until the next
// attempt.
releaseEngine()
if notifyAppState {
DispatchQueue.main.async { [weak self] in
self?.onAudioDeviceChanged?()
}
}
}
/// Returns whether a configuration notification is close enough to recording
/// startup to be treated as device/profile setup churn instead of a live
/// device interruption.
static func shouldTreatAsStartupConfigurationChange(
elapsedSinceRecordingStart: TimeInterval,
recoveryWindow: TimeInterval = startupConfigurationChangeRecoveryWindow
) -> Bool {
elapsedSinceRecordingStart >= 0
&& elapsedSinceRecordingStart <= recoveryWindow
}
// MARK: - Audio Processing
/// Whether silence detection has already fired (prevents repeated callbacks)
private var silenceCallbackFired = false
/// Whether max duration callback has already fired
private var maxDurationCallbackFired = false
/// Process an incoming audio buffer from AVAudioEngine
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, inputFormat: AVAudioFormat) {
guard isCurrentlyRecording else { return }
// Convert to whisper format (16kHz, mono, Float32)
guard let convertedBuffer = convertToWhisperFormat(buffer, from: inputFormat) else {
return
}
// Calculate audio energy for level reporting and silence detection
let energy = calculateRMSEnergy(convertedBuffer)
// Report audio level (throttled)
let now = Date()
if now.timeIntervalSince(lastLevelReportTime) >= levelReportInterval {
lastLevelReportTime = now
let normalizedLevel = min(energy / 0.3, 1.0) // Normalize to 0-1 range
onAudioLevel?(normalizedLevel)
}
// Always append audio samples to the buffer BEFORE checking stop conditions.
// This ensures no audio frames are discarded when silence or max duration
// is detected — the triggering frame and any trailing audio are preserved.
if let channelData = convertedBuffer.floatChannelData {
let frameCount = Int(convertedBuffer.frameLength)
bufferQueue.sync {
audioBuffer.reserveCapacity(audioBuffer.count + frameCount)
for i in 0..<frameCount {
audioBuffer.append(channelData[0][i])
}
}
}
// Check max duration (fire callback only once)
let elapsed = now.timeIntervalSince(recordingStartTime)
if elapsed >= maxDuration && !maxDurationCallbackFired {
maxDurationCallbackFired = true
DispatchQueue.main.async { [weak self] in
self?.onMaxDurationReached?()
}
return
}
// Silence detection
if energy > silenceThreshold {
lastSoundTime = now
silenceCallbackFired = false // Reset so silence can be detected again after speech resumes
} else if now.timeIntervalSince(lastSoundTime) >= silenceDuration && !silenceCallbackFired {
silenceCallbackFired = true
DispatchQueue.main.async { [weak self] in
self?.onSilenceDetected?()
}
}
}
/// Convert an audio buffer to whisper.cpp's required format (16kHz, mono, Float32)
private func convertToWhisperFormat(
_ buffer: AVAudioPCMBuffer,
from inputFormat: AVAudioFormat
) -> AVAudioPCMBuffer? {
let whisperFormat = AudioEngine.whisperFormat
// If input is already in the right format, return as-is
if inputFormat.sampleRate == whisperFormat.sampleRate
&& inputFormat.channelCount == whisperFormat.channelCount
&& inputFormat.commonFormat == whisperFormat.commonFormat {
return buffer
}
// Create a converter
guard let converter = AVAudioConverter(from: inputFormat, to: whisperFormat) else {
VocaLogger.error(.audioEngine, "Failed to create audio format converter")
return nil
}
// Calculate output frame capacity based on sample rate ratio
let ratio = whisperFormat.sampleRate / inputFormat.sampleRate
let outputFrameCapacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
guard let outputBuffer = AVAudioPCMBuffer(
pcmFormat: whisperFormat,
frameCapacity: outputFrameCapacity
) else {
return nil
}
var error: NSError?
let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
outStatus.pointee = .haveData
return buffer
}
converter.convert(to: outputBuffer, error: &error, withInputFrom: inputBlock)
if let error = error {
VocaLogger.error(.audioEngine, "Conversion error: \(error)")
return nil
}
return outputBuffer
}
/// Calculate the RMS (root mean square) energy of an audio buffer
private func calculateRMSEnergy(_ buffer: AVAudioPCMBuffer) -> Float {
guard let channelData = buffer.floatChannelData else { return 0.0 }
let frameCount = Int(buffer.frameLength)
guard frameCount > 0 else { return 0.0 }
var sumSquares: Float = 0.0
for i in 0..<frameCount {
let sample = channelData[0][i]
sumSquares += sample * sample
}
return sqrt(sumSquares / Float(frameCount))
}
// MARK: - Audio Device Enumeration
/// List available audio input devices.
static func availableInputDevices() -> [AudioDevice] {
let defaultDeviceID = defaultInputAudioDeviceID()
return inputAudioDeviceIDs().compactMap { deviceID in
guard let uid = audioDeviceUID(for: deviceID),
let name = audioDeviceName(for: deviceID) else {
return nil
}
return AudioDevice(
id: uid,
name: name,
isDefault: deviceID == defaultDeviceID,
sampleRate: audioDeviceSampleRate(for: deviceID),
channelCount: inputChannelCount(for: deviceID)
)
}
.sorted { lhs, rhs in
if lhs.isDefault != rhs.isDefault { return lhs.isDefault }
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
}
/// Configure this engine's input unit to use a specific Core Audio device.
/// This is scoped to VocaMac's AudioUnit and does not change macOS' global default input.
private func configurePreferredInputDevice(_ preferredInputDeviceID: String?, on inputNode: AVAudioInputNode) {
guard let preferredInputDeviceID,
!preferredInputDeviceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
VocaLogger.debug(.audioEngine, "Using system default input device")
return
}
guard let deviceID = Self.inputAudioDeviceID(forUID: preferredInputDeviceID) else {
VocaLogger.warning(.audioEngine, "Preferred input device unavailable, falling back to system default: \(preferredInputDeviceID)")
return
}
guard let audioUnit = inputNode.audioUnit else {
VocaLogger.warning(.audioEngine, "Input node has no AudioUnit; falling back to system default input")
return
}
var mutableDeviceID = deviceID
let status = AudioUnitSetProperty(
audioUnit,
kAudioOutputUnitProperty_CurrentDevice,
kAudioUnitScope_Global,
0,
&mutableDeviceID,
UInt32(MemoryLayout<AudioDeviceID>.size)
)
guard status == noErr else {
VocaLogger.warning(.audioEngine, "Failed to set preferred input device \(preferredInputDeviceID): OSStatus \(status)")
return
}
let deviceName = Self.audioDeviceName(for: deviceID) ?? preferredInputDeviceID
VocaLogger.info(.audioEngine, "Using preferred input device: \(deviceName)")
}
private static func inputAudioDeviceID(forUID uid: String) -> AudioDeviceID? {
inputAudioDeviceIDs().first { audioDeviceUID(for: $0) == uid }
}
private static func inputAudioDeviceIDs() -> [AudioDeviceID] {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var dataSize: UInt32 = 0
let systemObjectID = AudioObjectID(kAudioObjectSystemObject)
guard AudioObjectGetPropertyDataSize(systemObjectID, &address, 0, nil, &dataSize) == noErr else {
VocaLogger.warning(.audioEngine, "Failed to read Core Audio device list size")
return []
}
let deviceCount = Int(dataSize) / MemoryLayout<AudioDeviceID>.size
guard deviceCount > 0 else { return [] }
var deviceIDs = [AudioDeviceID](repeating: AudioDeviceID(kAudioObjectUnknown), count: deviceCount)
let status = AudioObjectGetPropertyData(systemObjectID, &address, 0, nil, &dataSize, &deviceIDs)
guard status == noErr else {
VocaLogger.warning(.audioEngine, "Failed to read Core Audio device list: OSStatus \(status)")
return []
}
return deviceIDs.filter { inputChannelCount(for: $0) > 0 }
}
private static func defaultInputAudioDeviceID() -> AudioDeviceID? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var deviceID = AudioDeviceID(kAudioObjectUnknown)
var dataSize = UInt32(MemoryLayout<AudioDeviceID>.size)
let status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&address,
0,
nil,
&dataSize,
&deviceID
)
guard status == noErr, deviceID != AudioDeviceID(kAudioObjectUnknown) else {
return nil
}
return deviceID
}
private static func audioDeviceUID(for deviceID: AudioDeviceID) -> String? {
stringProperty(kAudioDevicePropertyDeviceUID, for: deviceID)
}
private static func audioDeviceName(for deviceID: AudioDeviceID) -> String? {
stringProperty(kAudioObjectPropertyName, for: deviceID)
}
private static func stringProperty(_ selector: AudioObjectPropertySelector, for deviceID: AudioDeviceID) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var value: Unmanaged<CFString>?
var dataSize = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, &value)
guard status == noErr, let value else { return nil }
return value.takeRetainedValue() as String
}
private static func audioDeviceSampleRate(for deviceID: AudioDeviceID) -> Double {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyNominalSampleRate,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var sampleRate = Float64(0)
var dataSize = UInt32(MemoryLayout<Float64>.size)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, &sampleRate)
guard status == noErr else { return 0 }
return sampleRate
}
private static func inputChannelCount(for deviceID: AudioDeviceID) -> Int {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioObjectPropertyScopeInput,
mElement: kAudioObjectPropertyElementMain
)
var dataSize: UInt32 = 0
guard AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &dataSize) == noErr,
dataSize > 0 else {
return 0
}
let rawPointer = UnsafeMutableRawPointer.allocate(
byteCount: Int(dataSize),
alignment: MemoryLayout<AudioBufferList>.alignment
)
defer { rawPointer.deallocate() }
let bufferList = rawPointer.bindMemory(to: AudioBufferList.self, capacity: 1)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, bufferList)
guard status == noErr else { return 0 }
return UnsafeMutableAudioBufferListPointer(bufferList).reduce(0) { total, buffer in
total + Int(buffer.mNumberChannels)
}
}
}
// MARK: - AudioDevice
/// Represents an available audio input device
struct AudioDevice: Identifiable, Hashable {
let id: String
let name: String
let isDefault: Bool
let sampleRate: Double
let channelCount: Int
}
// MARK: - AudioRecording Conformance
extension AudioEngine: AudioRecording {}