@@ -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 (
0 commit comments