Skip to content

fix(iOS): recover stream recorder from audio route changes#598

Open
JulianPscheid wants to merge 4 commits into
llfbandit:mainfrom
JulianPscheid:hedy-vendor-route-change
Open

fix(iOS): recover stream recorder from audio route changes#598
JulianPscheid wants to merge 4 commits into
llfbandit:mainfrom
JulianPscheid:hedy-vendor-route-change

Conversation

@JulianPscheid
Copy link
Copy Markdown

Summary

RecorderStreamDelegate on iOS does not react to AVAudioEngineConfigurationChange notifications. When the user plugs in or unplugs an external microphone (USB-C, Lightning, Bluetooth accessory) during an active streaming recording, the AVAudioEngine is stopped and uninitialized by the system (per Apple docs), the tap installed against the old inputNode is gone, and the PCM stream silently dies. The host app has no way to recover short of tearing down the entire AudioRecorder and 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) inputNode using its current input format, a fresh AVAudioConverter is 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:

  1. Start a PCM stream session with the built-in mic.
  2. Plug in the external mic mid-session.
  3. Observable: all audio input stops. No pause/resume or config update recovers it. Must call stop() + start() to resume.
  4. With the external mic plugged in first, start a session, then unplug mid-session: same symptom.

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:

  • Extract tap installation into a private installInputTap(bufferSize:) method that re-reads inputNode.inputFormat(forBus: 0) and builds a fresh AVAudioConverter. Callable both on initial start and after a configuration change.
  • Retain the RecordStreamHandler reference on the delegate (m_recordEventHandler) so the new tap closure can forward buffers to the same event sink after a re-tap.
  • Observe .AVAudioEngineConfigurationChange on the engine in start() and store the observer token in m_configurationChangeObserver.
  • In the handler handleConfigurationChange(), remove the stale tap, call installInputTap, and restart the engine if it is no longer running.
  • Tear down the observer in stop() and null out the retained handler.

No public API changes.

References

Test plan

  • Manual: plug/unplug USB-C mic during a PCM stream session on iPhone 15, verify audio continues on the new route.
  • Regression: normal start/stop of a PCM stream session still works.
  • Manual: repeat with an AAC-streaming session once the AAC encoder path gets similar coverage (this PR only touches the stream delegate, AAC falls through the same path).
  • Manual: repeat with a Bluetooth HFP headset connect/disconnect.

Notes

Filing this because we hit it in production on iOS via the Flutter record package. Happy to iterate on the fix if the maintainers prefer a different shape (e.g. observing AVAudioSession.routeChangeNotification additionally, 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.

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().
@llfbandit
Copy link
Copy Markdown
Owner

Thanks for your contribution, that's a good catch.

However, I would prefer to make this configurable with wider behaviours.
For example, the recording is started with a dedicated microphone which disables noisy sounds around. If this device is unplugged the recording will continue with an ambient audio which may be not desired.

Making this configurable allows to introduce "continue", "pause" or "stop" behaviours.
Also more platforms and outputs could benefit from this change, this would help to keep consistency between platforms with a clearer and known ("public") behaviour.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants