Skip to content

Commit 23592f8

Browse files
authored
fix: retire AVAudioEngine after configuration changes (#216)
Bluetooth HFP settle posts AVAudioEngineConfigurationChange, and releaseEngine() in that handler deallocates AVAudioIOUnit while Core Audio is still in IOUnitPropertyListener (EXC_BAD_ACCESS on macOS 26). Detach the invalidated engine immediately so the next recording gets a fresh instance, keep the old object alive until AVFAudio callbacks drain, and restart the graph in place when a recording is still active. Hide CADefaultDeviceAggregate from the mic picker and treat it as a healthy transient route during profile switches. Fixes #196
1 parent ab480ee commit 23592f8

2 files changed

Lines changed: 253 additions & 21 deletions

File tree

Sources/VocaMac/Services/AudioEngine.swift

Lines changed: 166 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ final class AudioEngine {
2424
private var audioBuffer: [Float] = []
2525
private var _isCurrentlyRecording = false
2626
private var configuredInputDeviceID: AudioDeviceID?
27+
/// Last UID passed to `startRecording`, used to rebuild the graph after a
28+
/// configuration change without dropping the user's selected microphone.
29+
private var lastPreferredInputDeviceUID: String?
2730
private let bufferQueue = DispatchQueue(label: "com.vocamac.audio-buffer", qos: .userInteractive)
2831
private let lifecycleQueue = DispatchQueue(label: "com.vocamac.audio-engine.lifecycle", qos: .userInitiated)
2932
private let recordingPreparationLock = NSLock()
@@ -82,8 +85,8 @@ final class AudioEngine {
8285
/// Called when max recording duration is reached
8386
var onMaxDurationReached: (() -> Void)?
8487

85-
/// Called when the audio device configuration changes (e.g., mic unplugged/replugged).
86-
/// The engine is automatically stopped and reset when this happens.
88+
/// Called when a recording cannot be recovered after an audio route change
89+
/// (mic unplugged, Bluetooth disconnect, or a failed in-place restart).
8790
/// AppState should use this to recover from a stuck recording state.
8891
var onAudioDeviceChanged: (() -> Void)?
8992

@@ -166,9 +169,10 @@ final class AudioEngine {
166169
/// This happens when a microphone is unplugged/replugged, Bluetooth audio
167170
/// disconnects, or the default audio device changes (e.g., after sleep).
168171
///
169-
/// When this fires during an active recording, the engine's internal state
170-
/// is invalidated — the installed tap references a stale format and no audio
171-
/// flows. We must stop, reset, and notify AppState so it can recover.
172+
/// Apple stops the engine and posts this notification. Do not deallocate
173+
/// `AVAudioEngine` from the handler — AVFAudio may still be running
174+
/// `AVAudioIOUnit` property listeners on another queue.
175+
/// https://developer.apple.com/documentation/avfaudio/avaudioengineconfigurationchangenotification
172176
@objc private func handleAudioConfigurationChange(_ notification: Notification) {
173177
// Capture preparation state on the posting queue. startRecording holds
174178
// lifecycleQueue during Bluetooth HFP settle, so reading later would race.
@@ -194,36 +198,105 @@ final class AudioEngine {
194198
engineIsRunning: self.engine?.isRunning == true,
195199
elapsedSinceRecordingStart: elapsedSinceRecordingStart,
196200
configuredInputDeviceID: self.configuredInputDeviceID,
197-
currentInputDeviceID: currentInputDeviceID
201+
currentInputDeviceID: currentInputDeviceID,
202+
currentDeviceName: currentInputDeviceID.flatMap(Self.audioDeviceName),
203+
currentDeviceUID: currentInputDeviceID.flatMap(Self.audioDeviceUID)
198204
) {
199205
VocaLogger.info(.audioEngine, "Configuration change did not disrupt the configured recording route")
200206
return
201207
}
202208

203209
if wasRecording {
204-
VocaLogger.warning(.audioEngine, "Configuration changed while recording — forcing stop and reset")
205-
// Tear down the stale recording state
210+
VocaLogger.warning(.audioEngine, "Configuration changed while recording — restarting audio graph")
211+
self.setIsPreparingRecording(true)
212+
let recovered = self.restartRecordingGraph()
213+
self.setIsPreparingRecording(false)
214+
if recovered {
215+
VocaLogger.info(.audioEngine, "Audio graph restarted after configuration change")
216+
return
217+
}
218+
219+
VocaLogger.warning(.audioEngine, "Failed to restart audio graph after configuration change")
206220
self._isCurrentlyRecording = false
207221
self.silenceCallbackFired = false
208222
self.maxDurationCallbackFired = false
209223
self.removeInputTap(reason: "audio configuration change")
210-
self.engine?.stop()
211-
}
212-
213-
// Drop the engine entirely so the next recording starts from a
214-
// clean instance bound to the new default device.
215-
self.releaseEngine()
216-
VocaLogger.info(.audioEngine, "Audio engine released after configuration change")
217-
218-
if wasRecording {
219-
// Notify AppState on the main queue so it can handle the interrupted recording
224+
self.retireEngineAfterConfigurationChange()
220225
DispatchQueue.main.async { [weak self] in
221226
self?.onAudioDeviceChanged?()
222227
}
228+
return
223229
}
230+
231+
// Idle engine was invalidated (typical HFP → A2DP fallback after stop).
232+
// Detach it now so the next recording gets a fresh instance, but keep
233+
// the old object alive until AVFAudio finishes its callbacks.
234+
self.retireEngineAfterConfigurationChange()
235+
}
236+
}
237+
238+
/// Detaches the current engine so the next recording creates a new one,
239+
/// without deallocating the old `AVAudioEngine` on this turn.
240+
/// Must be called on `lifecycleQueue`.
241+
private func retireEngineAfterConfigurationChange() {
242+
pendingEngineRelease?.cancel()
243+
pendingEngineRelease = nil
244+
configuredInputDeviceID = nil
245+
246+
guard let oldEngine = engine else { return }
247+
248+
NotificationCenter.default.removeObserver(
249+
self,
250+
name: .AVAudioEngineConfigurationChange,
251+
object: oldEngine
252+
)
253+
engine = nil
254+
VocaLogger.info(.audioEngine, "Audio engine retired after configuration change")
255+
256+
lifecycleQueue.asyncAfter(deadline: .now() + Self.idleEngineReleaseDelay) {
257+
withExtendedLifetime(oldEngine) {}
224258
}
225259
}
226260

261+
/// Rebuilds the input tap and restarts the existing engine after Apple
262+
/// stops it for a configuration change. Keeps captured audio and does not
263+
/// notify AppState on success.
264+
/// Must be called on `lifecycleQueue`.
265+
private func restartRecordingGraph() -> Bool {
266+
guard let engine else { return false }
267+
268+
removeInputTap(reason: "configuration-change restart")
269+
engine.stop()
270+
engine.reset()
271+
272+
let inputNode = engine.inputNode
273+
guard let configuredInputDeviceID = configureInputRoute(
274+
preferredInputDeviceID: lastPreferredInputDeviceUID,
275+
engine: engine,
276+
inputNode: inputNode
277+
) else {
278+
return false
279+
}
280+
self.configuredInputDeviceID = configuredInputDeviceID
281+
282+
let inputFormat = inputNode.outputFormat(forBus: 0)
283+
guard isValidInputFormat(inputFormat) else {
284+
VocaLogger.error(
285+
.audioEngine,
286+
"Invalid input format after configuration change: sampleRate=\(inputFormat.sampleRate), channels=\(inputFormat.channelCount)"
287+
)
288+
return false
289+
}
290+
291+
if let startFailure = installTapAndStart(engine: engine, inputNode: inputNode, inputFormat: inputFormat) {
292+
VocaLogger.error(.audioEngine, "Failed to restart audio engine after configuration change: \(startFailure)")
293+
removeInputTap(reason: "configuration-change restart failure")
294+
return false
295+
}
296+
297+
return engine.isRunning
298+
}
299+
227300
// MARK: - Permission Handling
228301

229302
/// Check current microphone permission status (tri-state)
@@ -270,6 +343,7 @@ final class AudioEngine {
270343
self.setIsPreparingRecording(true)
271344
defer { self.setIsPreparingRecording(false) }
272345

346+
self.lastPreferredInputDeviceUID = preferredInputDeviceID
273347
self.silenceThreshold = silenceThreshold
274348
self.silenceDuration = silenceDuration
275349
self.maxDuration = maxDuration
@@ -461,6 +535,38 @@ final class AudioEngine {
461535
}
462536
}
463537

538+
/// Installs the input tap and starts the engine. Returns a description of
539+
/// the failure, or `nil` when `engine.isRunning` after start.
540+
private func installTapAndStart(
541+
engine: AVAudioEngine,
542+
inputNode: AVAudioInputNode,
543+
inputFormat: AVAudioFormat
544+
) -> String? {
545+
var startError: Error?
546+
let exception = VocaObjCExceptionCatcher.catchException { [weak self] in
547+
guard let self else { return }
548+
inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
549+
self?.processAudioBuffer(buffer, inputFormat: inputFormat)
550+
}
551+
engine.prepare()
552+
do {
553+
try engine.start()
554+
} catch {
555+
startError = error
556+
}
557+
}
558+
if let exception {
559+
return exception.localizedDescription
560+
}
561+
if let startError {
562+
return Self.describeCoreAudioError(startError)
563+
}
564+
guard engine.isRunning else {
565+
return "engine stopped during start"
566+
}
567+
return nil
568+
}
569+
464570
/// Restores AudioEngine to a clean idle state after any failed start attempt.
465571
private func recoverFromStartFailure(notifyAppState: Bool) {
466572
_isCurrentlyRecording = false
@@ -496,6 +602,8 @@ final class AudioEngine {
496602
elapsedSinceRecordingStart: TimeInterval,
497603
configuredInputDeviceID: AudioDeviceID?,
498604
currentInputDeviceID: AudioDeviceID?,
605+
currentDeviceName: String? = nil,
606+
currentDeviceUID: String? = nil,
499607
recoveryWindow: TimeInterval = startupConfigurationChangeRecoveryWindow
500608
) -> Bool {
501609
// Still inside configureInputRoute / startRecording on lifecycleQueue.
@@ -505,7 +613,9 @@ final class AudioEngine {
505613

506614
let routeIsHealthy = isConfiguredRouteHealthy(
507615
configuredInputDeviceID: configuredInputDeviceID,
508-
currentInputDeviceID: currentInputDeviceID
616+
currentInputDeviceID: currentInputDeviceID,
617+
currentDeviceName: currentDeviceName,
618+
currentDeviceUID: currentDeviceUID
509619
)
510620

511621
// A notification posted during preparation but processed after start must
@@ -525,20 +635,40 @@ final class AudioEngine {
525635
/// acceptable Bluetooth HFP sibling of that headset.
526636
static func isConfiguredRouteHealthy(
527637
configuredInputDeviceID: AudioDeviceID?,
528-
currentInputDeviceID: AudioDeviceID?
638+
currentInputDeviceID: AudioDeviceID?,
639+
currentDeviceName: String? = nil,
640+
currentDeviceUID: String? = nil
529641
) -> Bool {
530642
guard let configuredInputDeviceID, let currentInputDeviceID else {
531643
return false
532644
}
533645
if configuredInputDeviceID == currentInputDeviceID {
534646
return true
535647
}
648+
// Core Audio exposes a temporary default aggregate while Bluetooth
649+
// flips between A2DP and HFP. That is settle churn, not a lost mic.
650+
if isTransientCoreAudioAggregate(name: currentDeviceName, uid: currentDeviceUID) {
651+
return true
652+
}
536653
return isAcceptableBluetoothRouteSubstitute(
537654
targetDeviceID: configuredInputDeviceID,
538655
actualDeviceID: currentInputDeviceID
539656
)
540657
}
541658

659+
/// Core Audio's internal default-device wrapper. It shows up during
660+
/// Bluetooth profile switches and is not a real microphone.
661+
static func isTransientCoreAudioAggregate(name: String?, uid: String?) -> Bool {
662+
let prefix = "CADefaultDeviceAggregate"
663+
if let name, name.hasPrefix(prefix) { return true }
664+
if let uid, uid.hasPrefix(prefix) { return true }
665+
return false
666+
}
667+
668+
static func shouldExposeInputDevice(name: String, uid: String) -> Bool {
669+
!isTransientCoreAudioAggregate(name: name, uid: uid)
670+
}
671+
542672
static func describeCoreAudioError(_ error: Error) -> String {
543673
let nsError = error as NSError
544674
var parts = [nsError.localizedDescription, "domain=\(nsError.domain)", "code=\(nsError.code)"]
@@ -700,7 +830,8 @@ final class AudioEngine {
700830

701831
return inputAudioDeviceIDs().compactMap { deviceID in
702832
guard let uid = audioDeviceUID(for: deviceID),
703-
let name = audioDeviceName(for: deviceID) else {
833+
let name = audioDeviceName(for: deviceID),
834+
shouldExposeInputDevice(name: name, uid: uid) else {
704835
return nil
705836
}
706837

@@ -862,6 +993,13 @@ final class AudioEngine {
862993
let substituteName = Self.audioDeviceName(for: deviceIDAfterReset) ?? "bluetooth input"
863994
VocaLogger.info(.audioEngine, "Using Bluetooth HFP input endpoint: \(substituteName)")
864995
resolvedDeviceID = deviceIDAfterReset
996+
} else if let deviceIDAfterReset,
997+
Self.isTransientCoreAudioAggregate(deviceID: deviceIDAfterReset) {
998+
// During A2DP → HFP, Core Audio briefly routes through its default
999+
// aggregate. Keep the requested device as the logical target so
1000+
// later health checks still match the headset.
1001+
VocaLogger.info(.audioEngine, "Input route is on a transient Core Audio aggregate; keeping requested device")
1002+
resolvedDeviceID = targetDeviceID
8651003
} else {
8661004
let actualDescription = deviceIDAfterReset.map(String.init) ?? "none"
8671005
VocaLogger.warning(
@@ -966,6 +1104,13 @@ final class AudioEngine {
9661104
)
9671105
}
9681106

1107+
private static func isTransientCoreAudioAggregate(deviceID: AudioDeviceID) -> Bool {
1108+
isTransientCoreAudioAggregate(
1109+
name: audioDeviceName(for: deviceID),
1110+
uid: audioDeviceUID(for: deviceID)
1111+
)
1112+
}
1113+
9691114
private static func applyInputDevice(_ deviceID: AudioDeviceID, to audioUnit: AudioUnit) -> Bool {
9701115
var mutableDeviceID = deviceID
9711116
let status = AudioUnitSetProperty(

Tests/VocaMacTests/ServiceTests.swift

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,93 @@ final class AudioEngineDeviceChangeTests: XCTestCase {
946946
)
947947
}
948948

949+
func testTransientCoreAudioAggregateDetection() {
950+
XCTAssertTrue(
951+
AudioEngine.isTransientCoreAudioAggregate(
952+
name: "CADefaultDeviceAggregate",
953+
uid: "CADefaultDeviceAggregate-0x8a"
954+
)
955+
)
956+
XCTAssertTrue(
957+
AudioEngine.isTransientCoreAudioAggregate(
958+
name: "CADefaultDeviceAggregate-0x8a",
959+
uid: "other-uid"
960+
)
961+
)
962+
XCTAssertTrue(
963+
AudioEngine.isTransientCoreAudioAggregate(
964+
name: "MacBook Pro Microphone",
965+
uid: "CADefaultDeviceAggregate-0x8a"
966+
)
967+
)
968+
XCTAssertFalse(
969+
AudioEngine.isTransientCoreAudioAggregate(
970+
name: "soundcore Liberty 5",
971+
uid: "00-11-22-33"
972+
)
973+
)
974+
XCTAssertFalse(
975+
AudioEngine.isTransientCoreAudioAggregate(name: nil, uid: nil)
976+
)
977+
}
978+
979+
func testInternalCoreAudioAggregateIsHiddenFromPicker() {
980+
XCTAssertFalse(
981+
AudioEngine.shouldExposeInputDevice(
982+
name: "CADefaultDeviceAggregate",
983+
uid: "CADefaultDeviceAggregate-0x1"
984+
)
985+
)
986+
XCTAssertTrue(
987+
AudioEngine.shouldExposeInputDevice(
988+
name: "soundcore Liberty 5",
989+
uid: "00-11-22-33"
990+
)
991+
)
992+
XCTAssertTrue(
993+
AudioEngine.shouldExposeInputDevice(
994+
name: "MacBook Pro Microphone",
995+
uid: "BuiltInMicrophoneDevice"
996+
)
997+
)
998+
}
999+
1000+
func testConfiguredRouteHealthTreatsTransientAggregateAsHealthy() {
1001+
XCTAssertTrue(
1002+
AudioEngine.isConfiguredRouteHealthy(
1003+
configuredInputDeviceID: 42,
1004+
currentInputDeviceID: 41,
1005+
currentDeviceName: "CADefaultDeviceAggregate",
1006+
currentDeviceUID: "CADefaultDeviceAggregate-0x1"
1007+
),
1008+
"CADefaultDeviceAggregate during Bluetooth settle is not a lost mic"
1009+
)
1010+
XCTAssertFalse(
1011+
AudioEngine.isConfiguredRouteHealthy(
1012+
configuredInputDeviceID: 42,
1013+
currentInputDeviceID: 41,
1014+
currentDeviceName: "MacBook Pro Microphone",
1015+
currentDeviceUID: "BuiltInMicrophoneDevice"
1016+
),
1017+
"A real different device is still an unhealthy route"
1018+
)
1019+
}
1020+
1021+
func testStartupConfigurationChangeIgnoresTransientAggregate() {
1022+
XCTAssertTrue(
1023+
AudioEngine.shouldIgnoreConfigurationChange(
1024+
isRecording: true,
1025+
engineIsRunning: true,
1026+
elapsedSinceRecordingStart: 0.9,
1027+
configuredInputDeviceID: 42,
1028+
currentInputDeviceID: 41,
1029+
currentDeviceName: "CADefaultDeviceAggregate",
1030+
currentDeviceUID: "CADefaultDeviceAggregate-0x1"
1031+
),
1032+
"Bluetooth settle that lands on CADefaultDeviceAggregate must not abort recording"
1033+
)
1034+
}
1035+
9491036
func testStartupConfigurationChangeIsIgnoredOnlyForStableConfiguredRoute() {
9501037
XCTAssertTrue(
9511038
AudioEngine.shouldIgnoreConfigurationChange(

0 commit comments

Comments
 (0)