Audio latency on Android is a chain, not a setting. A drum hit passes through the input buffer, the app's mixer, the system mixer and the output buffer before it reaches a speaker. Each stage has a minimum, and the knob labelled “low latency” only helps with two of them.
Where the milliseconds go
- Input capture. 5 to 20 ms on a phone microphone path; irrelevant for touch-triggered sounds, dominant for anything recording.
- App buffer. The number you control. Frames divided by sample rate, doubled if the API uses double buffering.
- System mixer and effects. 0 to 30 ms depending on whether the device applies effects. An exclusive stream bypasses most of it.
- Output buffer and amplifier. 5 to 25 ms, and the part that varies most between phones.
A 48 kHz stream with 192 frames per callback is 4 ms per buffer. The same stream through a device that resamples to 44.1 kHz and applies a 10 ms effect can measure 25 ms round trip even though the app did everything right.
AAudio and Oboe: which layer to write against
AAudio is the platform API, available from Android 8. It offers a low-latency path over MMAP when the device supports it, and exclusive mode when nothing else is using the output. Oboe is a small C++ wrapper that talks to AAudio on new devices and OpenSL ES on old ones, with one API.
| AAudio | Oboe | |
|---|---|---|
| Minimum API | 26 | 16, with a fallback path |
| Low-latency MMAP | Yes, when available | Yes, through AAudio |
| Exclusive mode | Yes | Yes, translated |
| Sample rate conversion | Yours to handle | Optional helper |
| Error reporting | aaudio_result_t | Result plus a text code |
If your minimum is Android 8, AAudio is enough and one less dependency. If you support older devices, or you want the stream builder that handles the fallback, Oboe saves a week of platform work.
The settings that matter
Four settings decide most of the result, and three of them are requests the device may refuse.
class Engine : public oboe::AudioStreamDataCallback {
public:
oboe::DataCallbackResult onAudioReady(
oboe::AudioStream* stream, void* audio, int32_t frames) override {
auto* out = static_cast<float*>(audio);
mixer_.render(out, frames, stream->getChannelCount());
return oboe::DataCallbackResult::Continue;
}
Mixer mixer_;
};
void start(oboe::AudioStream*& stream) {
oboe::AudioStreamBuilder b;
b.setDirection(oboe::Direction::Output)
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
->setSharingMode(oboe::SharingMode::Exclusive)
->setFormat(oboe::AudioFormat::Float)
->setChannelCount(2)
->setSampleRate(48000)
->setFramesPerDataCallback(192)
->setDataCallback(&engine);
if (b.openStream(stream) != oboe::Result::OK) return;
stream->requestStart();
}
Ask for the device's native sample rate when you can. Oboe can report it after opening, and opening with the wrong rate forces a resampler that costs both latency and battery. Opening twice — once with a guess, once with the reported rate — is acceptable during bring-up, but ship one stream.
Buffer size is a real trade-off. The device has a minimum, getFramesPerBurst(), and a maximum. Setting the buffer to one burst gives the lowest latency and the least tolerance for a slow callback. Two bursts is a reasonable default; three is what we ship in a game that also runs a physics thread.
int32_t burst = stream->getFramesPerBurst(); // usually 96 or 192
stream->setBufferSizeInFrames(burst * 2); // may be clamped
Measurements
Round trip measured with a loopback cable: a click played, recorded, and aligned by correlation. Fifty runs per device, warm, screen on.
| Device | Native rate | Burst | Buffer | p50 | p99 |
|---|---|---|---|---|---|
| Flagship, 2025 | 48 kHz | 96 | 192 | 11.8 ms | 18.4 ms |
| Foldable, 2025 | 48 kHz | 96 | 192 | 12.9 ms | 22.0 ms |
| Flagship, 2023 | 48 kHz | 192 | 384 | 16.2 ms | 26.1 ms |
| Mid-range, 2024 | 48 kHz | 240 | 480 | 21.7 ms | 34.9 ms |
| Budget, 2023 | 44.1 kHz | 256 | 512 | 27.3 ms | 52.6 ms |
The p99 column is the interesting one. A device that averages 12 ms but spikes to 22 will produce an audible click when a callback overruns, and no amount of buffer tuning fixes a callback that does file I/O or locks a mutex the UI thread holds.
Underruns
The callback runs on a real-time thread with a deadline equal to the buffer duration. Four milliseconds is not enough time to allocate memory, parse a file or take a contended lock. Our rules:
- Mix into a preallocated buffer. No allocation in the callback, ever.
- Read input state from a lock-free queue; do not call into gameplay code that might wait.
- If the mixer cannot fill a buffer, write silence and count the event. A click is better than a crash and both should be in the log.
- Test with the screen recording and the notification shade open, which is when the system mixer is busiest.
Oboe exposes getXRunCount(); AAudio has the equivalent. Log it once per second during development. A count that grows after a level load points at the asset loader, not the audio code.
What we ship
- Exclusive mode, low-latency performance, float stereo at the device's native rate.
- Two bursts of buffer, raised to three if the first minute of play shows any underrun.
- A silence fallback and an xrun counter in every build, including release.
- A documented latency target per device tier, measured with the same loopback method, so a regression is a number and not an opinion.
The full stream setup, including the fallback path for devices that refuse exclusive mode, is in the sample project described in the July digest. For the input side of the same problem, see the measurement of controller latency in gamepad to photon.