fix(iOS): recover stream recorder from audio route changes#598
Open
JulianPscheid wants to merge 4 commits into
Open
fix(iOS): recover stream recorder from audio route changes#598JulianPscheid wants to merge 4 commits into
JulianPscheid wants to merge 4 commits into
Conversation
The stream recorder on iOS did not react to AVAudioEngineConfigurationChange notifications. When a user plugs in or unplugs an external microphone during an active recording, the AVAudioEngine is stopped and uninitialized by the system, and the tap installed against the old inputNode is gone, so the PCM stream silently dies until the host app fully stops and restarts the recorder. This change observes .AVAudioEngineConfigurationChange on the engine used by RecorderStreamDelegate. In the handler the stale tap is removed, the tap is reinstalled against the (recreated) inputNode using its current input format, a fresh AVAudioConverter is built for the new format, and the engine is restarted. The tap installation logic is extracted into installInputTap so it can run both on initial start() and on every subsequent configuration change. The recordEventHandler reference is retained on the delegate so the new tap closure can still forward buffers to the host app's event sink, and the observer is torn down in stop().
Incorporates review feedback on the earlier route-change recovery commit: - Also observe AVAudioSession.routeChangeNotification in addition to .AVAudioEngineConfigurationChange. The engine notification only fires on format changes (sample rate or channel count), so route changes where the new device happens to match the old format would otherwise be missed. Only handle .newDeviceAvailable, .oldDeviceUnavailable, and .routeConfigurationChange reasons. - Track m_shouldBeRunning so recovery does not auto-resume a user-paused recorder after a route change. Flag is set in start/resume, cleared in pause/stop, checked before recovery runs. - Serialize recovery onto a dedicated DispatchQueue. Notifications can arrive on arbitrary threads and mutating the engine from AVFoundation's posting thread is risky. The queue also coalesces overlapping events naturally.
When an app calls AVAudioSession.setPreferredInput during an active recording to switch to a user-picked mic, iOS posts AVAudioSession.routeChangeNotification with reason .override. The previous filter excluded that reason so our recovery handler did not fire, leaving the AVAudioEngine tap on the old route even though the session had switched. Added .override to the allowlist and a debug print so the triggering reason is visible in the console during testing. Recovery is idempotent on the serial queue, so any extra fire from .override alongside a simultaneous engineConfigurationChange is harmless.
.override fires for both input overrides (setPreferredInput) and output overrides (overrideOutputAudioPort / Control Center speakerphone toggle / AirPlay output change). Recovering on every .override caused a needless tap rebuild and audio glitch during output-only toggles even when the input route was unchanged. Track the currently-routed input UID (m_currentInputUID) and only trigger recovery on .override when the current route's first input UID differs from the cached one. Update the cached UID in start() and after each successful recovery; clear it in stop().
Owner
|
Thanks for your contribution, that's a good catch. However, I would prefer to make this configurable with wider behaviours. Making this configurable allows to introduce "continue", "pause" or "stop" behaviours. But that's a lot of digging I know... But I think it's best to try. If you're OK, we could work on this together. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
RecorderStreamDelegateon iOS does not react toAVAudioEngineConfigurationChangenotifications. When the user plugs in or unplugs an external microphone (USB-C, Lightning, Bluetooth accessory) during an active streaming recording, theAVAudioEngineis stopped and uninitialized by the system (per Apple docs), the tap installed against the oldinputNodeis gone, and the PCM stream silently dies. The host app has no way to recover short of tearing down the entireAudioRecorderand creating a new one, which causes a 1-2 second audio gap and is easy to get wrong from the Dart side.This change makes the streaming recorder resilient to route changes: on a configuration-change notification the stale tap is removed, the tap is reinstalled against the (recreated)
inputNodeusing its current input format, a freshAVAudioConverteris built, and the engine is restarted. Audio keeps flowing onto the new route with a minimal glitch.Reproduction
Tested on an iPhone 15 running the current public iOS with a USB-C external microphone. Before the fix:
stop()+start()to resume.After the fix: in both scenarios, audio continues on the new route after a brief glitch.
Changes
Only
record_ios/ios/record_ios/Sources/record_ios/delegate/RecorderStreamDelegate.swift:installInputTap(bufferSize:)method that re-readsinputNode.inputFormat(forBus: 0)and builds a freshAVAudioConverter. Callable both on initial start and after a configuration change.RecordStreamHandlerreference on the delegate (m_recordEventHandler) so the new tap closure can forward buffers to the same event sink after a re-tap..AVAudioEngineConfigurationChangeon the engine instart()and store the observer token inm_configurationChangeObserver.handleConfigurationChange(), remove the stale tap, callinstallInputTap, and restart the engine if it is no longer running.stop()and null out the retained handler.No public API changes.
References
Test plan
Notes
Filing this because we hit it in production on iOS via the Flutter
recordpackage. Happy to iterate on the fix if the maintainers prefer a different shape (e.g. observingAVAudioSession.routeChangeNotificationadditionally, debouncing, or dispatching the handler onto a specific queue). No unit tests in this PR because the notification path requires a real device; open to adding XCTestCase-based smoke coverage if you would like.