software-mansion / software-mansion/react-native-audio-api
[Android] Hard-coded PerformanceMode::LowLatency causes continuous underruns (crackle) for multi-source playback on low-end devices
Nobody has claimed this yet.
- Dominant language
- C++
- Stars
- 839
- Forks
- 92
- Avg merge
- 4d 15h
- Merged PRs (30d)
- 27
Description
Summary
On Android, AudioPlayer opens its Oboe output stream hard-coded to SharingMode::Exclusive + PerformanceMode::LowLatency with setFramesPerDataCallback(RENDER_QUANTUM_SIZE) and no buffer-size request (AudioPlayer.cpp#L38). On entry-level hardware the OS grants the minimum double buffer, and rendering a multi-source graph misses the callback deadline hundreds of times per second — heard as continuous crackle. The same graph on a PerformanceMode::None stream plays clean on the same device.
Since the whole audio graph renders inside the Oboe data callback, every buffer of headroom matters: LowLatency is the right default for interactive use, but there is currently no way to opt out for playback-heavy apps, which is exactly the case AudioContextOptions.latencyHint exists for in the Web Audio spec. Proposal below; happy to adjust.
Environment
react-native-audio-api0.13.2 (the stream configuration is unchanged on currentmain)- Entry-level Samsung phone (~$200 class), Android, AAudio backend, 48 kHz
- App plays ~11 simultaneous
AudioBufferQueueSourceNodelanes through per-track gains into a small master chain (all sourcesplaybackRate1.0, no pitch correction — the per-quantum DSP is mostly memcpy + multiplies)
Measurement
We temporarily patched 0.13.2 to log the granted stream configuration on open and AudioStream::getXRunCount() every 2048 callbacks (~5.5 s at 48 kHz / 128 frames).
Granted stream on this device:
[oboe] opened api=2 sharing=0 perf=12 rate=48000 burst=96 bufferSize=192 capacity=3072
AAudio, Exclusive, LowLatency — granted 192 frames (4 ms) of buffer out of a 3072-frame capacity, and with no latency tuner it stays there.
While the 11-source session played:
[oboe] xruns=1215 bufferSize=192 (t+11s)
[oboe] xruns=2726 bufferSize=192
[oboe] xruns=4284 bufferSize=192
...climbing ~1,100–1,250 per 5.5 s window, continuously...
[oboe] xruns=395578 bufferSize=192 (t+30min)
That is ~210–230 underruns per second, sustained — matching the audible continuous crackle. Two control observations pin it to render load rather than a data or scheduling bug in the app:
- The instant playback stopped, the counter froze at 395,578 and stayed frozen while the callback kept firing over silence (the idle stream renders zeros and meets every deadline).
- With the app idle but a background decode workload running, the counter also climbed — the callback loses the CPU to ordinary app work at a 2.7 ms deadline on little cores.
A/B on the same device, same session: reopening the stream with PerformanceMode::None (all else identical) → no audible crackle. iOS never exhibits the problem — the AVAudioSession default IO buffer (~23 ms) provides an order of magnitude more scheduling headroom than the 4 ms granted here.
Proposal: implement AudioContextOptions.latencyHint
The Web Audio API already models this exact trade (spec); the library currently ignores the option. Proposed mapping:
latencyHint |
Android (Oboe PerformanceMode) |
iOS | Web |
|---|---|---|---|
'interactive' (default) |
LowLatency — current behaviour, unchanged |
unchanged | forwarded to browser |
'balanced' |
None |
accepted, no stream change yet | forwarded to browser |
'playback' |
PowerSaving |
accepted, no stream change yet | forwarded to browser |
Design points:
- Non-breaking: omitted or unrecognized hints behave exactly as today (
interactive), matching how browsers treat unknown values. - The hint rides the
AudioContextconstructor chain (JSIcreateAudioContextgains an optional second argument) down to the platform player; only the Android branch consumes it initially. - Numeric hints (seconds) are out of scope for the first cut — on Android they would map naturally to
setBufferSizeInFrameswithin the granted capacity, listed as follow-up. - iOS wiring (e.g.
preferredIOBufferDuration) is likewise left as follow-up; iOS's default already provides deep-buffer behaviour.
I have a working implementation of the above (validated on the device that reproduced the bug, via a backport of the same change to 0.13.2) and will open a PR referencing this issue.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start at android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp around the Oboe stream configuration, then trace createAudioContext and the AudioContext constructor chain to the platform player. Implement the latencyHint mapping described in the issue, preserving interactive as the default and handling balanced and playback on Android. Verify that the existing default remains unchanged and that the new hints reach the Android stream configuration.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, cpp, react-native
- Domain
- audio-video-rtc, mobile-dev
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 38/100