Skip to content

iOS: recordings play slow-motion after a mid-recording route change (Bluetooth HFP) — recorder's resolved input format & AVAudioConverter are never re-prepared #1179

Description

@joshmohrer

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.

  1. 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.

  2. 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).

  3. 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.

  4. (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

  1. Connect a Bluetooth headset that uses HFP for its mic (or start with the built-in mic).
  2. Start a recording.
  3. Mid-recording, change the route: connect/disconnect the Bluetooth device, or take/end a call so the HFP route hands off.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions