iOS recordings come out "slow-motion" after a mid-recording audio-route change (Bluetooth HFP): file writer's converter keeps a stale input format
Package: react-native-audio-api 0.13.2 · Platform: iOS · Arch: New Architecture (Fabric/TurboModules) · Env: Expo / bare RN
Summary
When the active input route changes during a recording — most commonly a Bluetooth hands‑free (HFP) mic connecting/handing off (AirPods in call mode, car audio), or starting a recording right after a VoIP/phone call releases an HFP route — the resulting .m4a plays back slow-motion (roughly 2.75–3× too slow, voices pitched way down). The audio is real and intelligible, just stretched. The recovered sample-rate ratios are almost always 48000/16000 (3.0×), 44100/16000 (2.756×), or 44100/8000 (5.5×) — i.e. the true capture rate was a telephony/HFP rate (8k/16k) but the file was produced on a 44.1/48 kHz timeline.
Impact / prevalence
In a production app with a large iOS user base we see this on ~0.6–0.9% of all iOS recordings, spread across hundreds of distinct users and every modern iOS version (18.x and 26.x alike — so it is not an OS-version regression). It is intermittent per user (~7% of an affected user's recordings — only the ones where a low-rate route was involved), which is the fingerprint of a route-dependent trigger. The legacy expo-audio path on the same devices shows this ~10× less, consistent with a difference in how the two engines handle input-format changes.
Root cause (from reading the 0.13.2 iOS source)
The recorder resolves its input format and prepares its converters once, and there is no path that re-prepares them when the audio graph is rebuilt after a route/engine-configuration change.
-
Writer converter is frozen at openFile(). IOSFileWriter::openFile stores bufferFormat_ = bufferFormat and builds converter_ = [[AVAudioConverter alloc] initFromFormat:bufferFormat toFormat:[audioFile_ processingFormat]] once (ios/audioapi/ios/core/utils/IOSFileWriter.mm ~L58, ~L86). In taskOffloaderFunction it memcpys incoming raw bytes into a converterInputBuffer_ allocated for bufferFormat_, and compares only bufferFormat_ vs the file format — it never checks the incoming buffer's actual format (IOSFileWriter.mm ~L253–290). So if the live input rate changes, it keeps converting as if the input were still the original rate, producing the wrong number of output frames → stretched file.
-
Engine restart on route change does not re-prepare the recorder. SystemNotificationManager handles AVAudioEngineConfigurationChangeNotification and media-services-reset by markInactive + restartAudioEngine — rebuilding the graph — but never re-resolves getResolvedInputFormat nor re-runs the recorder's writer/callback/adapter preparation (ios/audioapi/ios/system/SystemNotificationManager.mm ~L237–250; route change at ~L200–222 only emits the JS routeChange event).
-
The re-preparation machinery already exists but is only wired to JS calls. IOSAudioRecorder::enableFileOutput, connect, and setOnAudioReadyCallback each re-read getResolvedInputFormat and re-prepare (the if (!isIdle()) branches at ios/audioapi/ios/core/IOSAudioRecorder.mm ~L343, ~L422, ~L515). Nothing invokes this flow in response to a route/config change.
-
(Possible secondary) start-time race. The input connection format appears to be materialized before the session's forced activation (ios/audioapi/ios/core/NativeAudioRecorder.m ~L95, ios/audioapi/ios/system/AudioEngine.mm ~L441), and AudioSessionManager's cached isActive can let setAudioSessionActivity(true) return without re-touching AVAudioSession (AudioSessionManager.mm ~L177). If the route is still settling when the format is resolved, a recording can start already-mismatched.
Reproduction
- Connect a Bluetooth headset that uses HFP for its mic (or start with the built-in mic).
- Start a recording.
- Mid-recording, change the route: connect/disconnect the Bluetooth device, or take/end a call so the HFP route hands off.
- Stop and play back → the recording (or the portion after the route change) is stretched/slow.
Also reproducible by starting a recording immediately after a VoIP/phone call that held an HFP route releases it.
Expected vs actual
- Expected: a route change during recording either transitions cleanly (converter re-prepared to the new input format, so output stays correct-tempo) or the recording fails/segments explicitly.
- Actual: the writer silently keeps the stale input format and produces a stretched file with no error.
Suggested fix (for maintainers)
On AVAudioEngineConfigurationChangeNotification / media-services reset while a recorder is active, re-resolve getResolvedInputFormat and re-prepare the recorder's file writer + onAudioReady callback + adapter converters (the same code the if (!isIdle()) branches already run) — opening a new file segment if the sample rate changed, since an open AVAudioFile can't change rate mid-file. Alternatively (or additionally), make IOSFileWriter/IOSRecorderCallback rebuild their AVAudioConverter when the incoming buffer's format no longer matches bufferFormat_, rather than assuming it never changes.
Related (this area is a known pain point)
We verified the mechanism is still present on current main: NativeAudioRecorder.m sets resolvedInputFormat exactly once in start: (and does not re-resolve it in resume), and there is no AVAudioEngineConfigurationChangeNotification / route-change handler in IOSAudioRecorder.mm that re-prepare()s the callback or file writer. Happy to test a fix against our fleet.
iOS recordings come out "slow-motion" after a mid-recording audio-route change (Bluetooth HFP): file writer's converter keeps a stale input format
Package: react-native-audio-api 0.13.2 · Platform: iOS · Arch: New Architecture (Fabric/TurboModules) · Env: Expo / bare RN
Summary
When the active input route changes during a recording — most commonly a Bluetooth hands‑free (HFP) mic connecting/handing off (AirPods in call mode, car audio), or starting a recording right after a VoIP/phone call releases an HFP route — the resulting
.m4aplays back slow-motion (roughly 2.75–3× too slow, voices pitched way down). The audio is real and intelligible, just stretched. The recovered sample-rate ratios are almost always 48000/16000 (3.0×), 44100/16000 (2.756×), or 44100/8000 (5.5×) — i.e. the true capture rate was a telephony/HFP rate (8k/16k) but the file was produced on a 44.1/48 kHz timeline.Impact / prevalence
In a production app with a large iOS user base we see this on ~0.6–0.9% of all iOS recordings, spread across hundreds of distinct users and every modern iOS version (18.x and 26.x alike — so it is not an OS-version regression). It is intermittent per user (~7% of an affected user's recordings — only the ones where a low-rate route was involved), which is the fingerprint of a route-dependent trigger. The legacy
expo-audiopath on the same devices shows this ~10× less, consistent with a difference in how the two engines handle input-format changes.Root cause (from reading the 0.13.2 iOS source)
The recorder resolves its input format and prepares its converters once, and there is no path that re-prepares them when the audio graph is rebuilt after a route/engine-configuration change.
Writer converter is frozen at
openFile().IOSFileWriter::openFilestoresbufferFormat_ = bufferFormatand buildsconverter_ = [[AVAudioConverter alloc] initFromFormat:bufferFormat toFormat:[audioFile_ processingFormat]]once (ios/audioapi/ios/core/utils/IOSFileWriter.mm~L58, ~L86). IntaskOffloaderFunctionitmemcpys incoming raw bytes into aconverterInputBuffer_allocated forbufferFormat_, and compares onlybufferFormat_vs the file format — it never checks the incoming buffer's actual format (IOSFileWriter.mm~L253–290). So if the live input rate changes, it keeps converting as if the input were still the original rate, producing the wrong number of output frames → stretched file.Engine restart on route change does not re-prepare the recorder.
SystemNotificationManagerhandlesAVAudioEngineConfigurationChangeNotificationand media-services-reset bymarkInactive+restartAudioEngine— rebuilding the graph — but never re-resolvesgetResolvedInputFormatnor re-runs the recorder's writer/callback/adapter preparation (ios/audioapi/ios/system/SystemNotificationManager.mm~L237–250; route change at ~L200–222 only emits the JSrouteChangeevent).The re-preparation machinery already exists but is only wired to JS calls.
IOSAudioRecorder::enableFileOutput,connect, andsetOnAudioReadyCallbackeach re-readgetResolvedInputFormatand re-prepare (theif (!isIdle())branches atios/audioapi/ios/core/IOSAudioRecorder.mm~L343, ~L422, ~L515). Nothing invokes this flow in response to a route/config change.(Possible secondary) start-time race. The input connection format appears to be materialized before the session's forced activation (
ios/audioapi/ios/core/NativeAudioRecorder.m~L95,ios/audioapi/ios/system/AudioEngine.mm~L441), andAudioSessionManager's cachedisActivecan letsetAudioSessionActivity(true)return without re-touchingAVAudioSession(AudioSessionManager.mm~L177). If the route is still settling when the format is resolved, a recording can start already-mismatched.Reproduction
Also reproducible by starting a recording immediately after a VoIP/phone call that held an HFP route releases it.
Expected vs actual
Suggested fix (for maintainers)
On
AVAudioEngineConfigurationChangeNotification/ media-services reset while a recorder is active, re-resolvegetResolvedInputFormatand re-prepare the recorder's file writer +onAudioReadycallback + adapter converters (the same code theif (!isIdle())branches already run) — opening a new file segment if the sample rate changed, since an openAVAudioFilecan't change rate mid-file. Alternatively (or additionally), makeIOSFileWriter/IOSRecorderCallbackrebuild theirAVAudioConverterwhen the incoming buffer's format no longer matchesbufferFormat_, rather than assuming it never changes.Related (this area is a known pain point)
AudioEnginerebuilds its graph on configuration/route changes and that "the most reliable trigger was Bluetooth… every switch is also a hardware route change." The 0.13.2 fix guarded re-entrancy but did not re-resolve the recorder's input format — which is the gap this report is about.IOSRecorderCallback.mm/AVAudioConverter) in the same "requested rate ≠ hardware rate" scenario (16 kHz callback on a 48 kHz input).We verified the mechanism is still present on current
main:NativeAudioRecorder.msetsresolvedInputFormatexactly once instart:(and does not re-resolve it inresume), and there is noAVAudioEngineConfigurationChangeNotification/ route-change handler inIOSAudioRecorder.mmthat re-prepare()s the callback or file writer. Happy to test a fix against our fleet.