Learn how modern C++ features like constexpr, std::atomic, std::span, and RAII patterns work together with Google Oboe library to build real-time, low-latency audio apps on Android.
Why C++ for Android Audio?
If you have ever tried to build an audio app on Android using Java or Kotlin, you probably hit a wall pretty quickly. The audio sounds fine at first, but then you hear it: clicks, pops, and that annoying delay between touching the screen and hearing a sound. That delay can be anywhere from 100 to 250 milliseconds. For a musical instrument app, that is basically unusable.
The root cause is not your code. It is the JVM itself. Java’s garbage collector can pause your thread at any moment, and when your audio callback needs to deliver samples every 5 to 10 milliseconds, even a small GC pause causes an audible glitch. The Android audio team at Google puts it bluntly in their documentation:
“Do not do any audio processing in Java. Keep all audio processing in C++.”
With C++, you get deterministic execution, direct memory control, and the ability to run on a high-priority audio thread without worrying about garbage collection. Apps like BandLab, SoundCloud, and Google’s own Sound Amplifier all use C++ for their audio engines. If you are still deciding whether native code belongs in your app at all, I work through that call in when and why to use C++ on Android. Here, let us see how you can do the same using modern C++ features.
The Tools: AAudio and Oboe
Android has two C++ audio APIs worth knowing about. AAudio is the native low-latency API that shipped with Android 8.0 (API 26). It talks directly to the audio hardware through memory-mapped buffers, which is how you get that sub-20ms latency.
The problem with AAudio is that it only works on Android 8.0 and above. If you need to support older devices, you would also need to write an OpenSL ES fallback. That is a lot of boilerplate.
This is where Oboe comes in. Oboe is Google’s open-source C++ library that wraps both AAudio and OpenSL ES. On Android 8.1+, it uses AAudio. On older devices, it falls back to OpenSL ES automatically. It also patches known platform bugs, so you do not have to deal with device-specific quirks.
To add Oboe to your project, update your CMakeLists.txt:
cmake_minimum_required(VERSION 3.22)
project(my_audio_app)
set(CMAKE_CXX_STANDARD 20)
find_package(oboe REQUIRED CONFIG)
add_library(audio-engine SHARED
src/main/cpp/AudioEngine.cpp
src/main/cpp/jni_bridge.cpp
)
target_link_libraries(audio-engine
oboe::oboe
log
)
And in your build.gradle.kts:
android {
defaultConfig {
externalNativeBuild {
cmake {
arguments("-DANDROID_STL=c++_shared")
}
}
}
buildFeatures {
prefab = true
}
}
dependencies {
implementation("com.google.oboe:oboe:1.9.0")
}
Setting Up Your Audio Engine with RAII
The first modern C++ pattern you should use is RAII (Resource Acquisition Is Initialization). The idea is simple: when you create an object, it acquires a resource. When the object goes out of scope, it releases that resource automatically. No manual cleanup needed.
Here is a basic audio engine class that manages an Oboe stream using smart pointers:
#include <oboe/Oboe.h>
#include <memory>
#include <atomic>
class AudioEngine : public oboe::AudioStreamDataCallback {
public:
AudioEngine() = default;
~AudioEngine() { stop(); }
// Non-copyable (audio streams should not be duplicated)
AudioEngine(const AudioEngine&) = delete;
AudioEngine& operator=(const AudioEngine&) = delete;
bool start() {
oboe::AudioStreamBuilder builder;
builder.setPerformanceMode(oboe::PerformanceMode::LowLatency)
.setSharingMode(oboe::SharingMode::Exclusive)
.setFormat(oboe::AudioFormat::Float)
.setChannelCount(oboe::ChannelCount::Stereo)
.setSampleRate(48000)
.setDataCallback(this);
auto result = builder.openStream(mStream);
if (result != oboe::Result::OK) {
return false;
}
result = mStream->requestStart();
return result == oboe::Result::OK;
}
void stop() {
if (mStream) {
mStream->requestStop();
mStream->close();
mStream.reset();
}
}
oboe::DataCallbackResult onAudioReady(
oboe::AudioStream* stream,
void* audioData,
int32_t numFrames) override;
private:
std::shared_ptr<oboe::AudioStream> mStream;
std::atomic<float> mFrequency{440.0f};
std::atomic<float> mAmplitude{0.5f};
float mPhase = 0.0f;
};
Notice a few things here. The destructor calls stop(), so the stream is always cleaned up when the engine is destroyed. The class is non-copyable because duplicating an audio stream makes no sense. And we use std::shared_ptr for the stream, which is the API Oboe recommends since version 1.7.
constexpr: Zero-Cost Audio Constants
Audio code is full of constants: sample rates, buffer sizes, wavetable lengths, mathematical values. In a real-time audio callback that runs hundreds of times per second, you want these computed at compile time, not at runtime.
This is exactly what constexpr gives you:
constexpr int SAMPLE_RATE = 48000;
constexpr int CHANNEL_COUNT = 2;
constexpr int FRAMES_PER_BUFFER = 192;
constexpr float TWO_PI = 2.0f * 3.14159265358979f;
constexpr int WAVETABLE_SIZE = 1024;
// Compile-time frequency-to-phase-increment conversion
constexpr float phaseIncrement(float frequency) {
return frequency * TWO_PI / static_cast<float>(SAMPLE_RATE);
}
// This is evaluated at compile time
constexpr float A4_INCREMENT = phaseIncrement(440.0f);
The compiler evaluates phaseIncrement(440.0f) at build time. At runtime, it is just a constant sitting in your binary. No division, no multiplication, no function call. This matters when your callback has roughly 4 milliseconds to fill a buffer.
std::atomic: Lock-Free Parameter Updates
Here is a common scenario. Your user drags a slider on the UI thread to change the volume. Meanwhile, the audio callback is running on a separate high-priority thread, reading that volume value hundreds of times per second. How do you share this value safely?
You cannot use a mutex. If the audio thread blocks waiting for a lock held by the UI thread, you get an audio glitch. Period. This is the single most important rule in real-time audio programming: never block the audio thread.
The solution is std::atomic:
class AudioEngine : public oboe::AudioStreamDataCallback {
private:
// These can be safely read from the audio thread
// and written from the UI thread
std::atomic<float> mGain{1.0f};
std::atomic<float> mFrequency{440.0f};
std::atomic<bool> mIsPlaying{false};
public:
// Called from UI thread (via JNI)
void setGain(float gain) {
mGain.store(gain, std::memory_order_relaxed);
}
void setFrequency(float freq) {
mFrequency.store(freq, std::memory_order_relaxed);
}
void setPlaying(bool playing) {
mIsPlaying.store(playing, std::memory_order_relaxed);
}
oboe::DataCallbackResult onAudioReady(
oboe::AudioStream* stream,
void* audioData,
int32_t numFrames) override
{
auto* output = static_cast<float*>(audioData);
if (!mIsPlaying.load(std::memory_order_relaxed)) {
// Fill with silence
std::fill_n(output, numFrames * CHANNEL_COUNT, 0.0f);
return oboe::DataCallbackResult::Continue;
}
float gain = mGain.load(std::memory_order_relaxed);
float freq = mFrequency.load(std::memory_order_relaxed);
float increment = freq * TWO_PI / SAMPLE_RATE;
for (int i = 0; i < numFrames; i++) {
float sample = std::sin(mPhase) * gain;
mPhase += increment;
if (mPhase >= TWO_PI) mPhase -= TWO_PI;
// Write to both channels
output[i * CHANNEL_COUNT] = sample;
output[i * CHANNEL_COUNT + 1] = sample;
}
return oboe::DataCallbackResult::Continue;
}
};
We use memory_order_relaxed here because we do not need ordering guarantees between different atomic variables. We just need each individual read/write to be atomic. This compiles down to a single load instruction on ARM, which is as cheap as it gets.
std::span: Safe Buffer Access Without Overhead
The audio callback gives you a raw void* pointer and a frame count. This is classic C territory, and it is easy to mess up: write past the end of the buffer, get the channel count wrong, or cast to the wrong type.
C++20’s std::span wraps this raw pointer into a safe, zero-cost view. No copies, no allocations, just type safety and bounds checking in debug builds:
#include <span>
oboe::DataCallbackResult onAudioReady(
oboe::AudioStream* stream,
void* audioData,
int32_t numFrames) override
{
int channelCount = stream->getChannelCount();
int totalSamples = numFrames * channelCount;
// Wrap raw pointer in a type-safe view
std::span<float> buffer(
static_cast<float*>(audioData),
totalSamples
);
float gain = mGain.load(std::memory_order_relaxed);
// Range-based for loop over the buffer
for (auto& sample : buffer) {
sample = generateNextSample() * gain;
}
return oboe::DataCallbackResult::Continue;
}
If you are stuck on C++17 (which is the NDK default), you can use a lightweight span backport or simply use a gsl::span from the Guidelines Support Library.
Lock-Free Ring Buffer: Passing Events Between Threads
Atomics work great for single values, but what if you need to pass a stream of events from the UI thread to the audio thread? Think MIDI note-on/note-off messages, or parameter automation data.
You need a lock-free Single-Producer Single-Consumer (SPSC) ring buffer. The UI thread pushes events in, and the audio callback pops them out, all without a single mutex:
#include <array>
#include <atomic>
#include <optional>
template<typename T, size_t Capacity>
class LockFreeQueue {
public:
bool push(const T& item) {
size_t current = mWritePos.load(std::memory_order_relaxed);
size_t next = (current + 1) % Capacity;
// Queue is full if next write position equals read position
if (next == mReadPos.load(std::memory_order_acquire)) {
return false;
}
mBuffer[current] = item;
mWritePos.store(next, std::memory_order_release);
return true;
}
std::optional<T> pop() {
size_t current = mReadPos.load(std::memory_order_relaxed);
// Queue is empty if read position equals write position
if (current == mWritePos.load(std::memory_order_acquire)) {
return std::nullopt;
}
T item = mBuffer[current];
mReadPos.store(
(current + 1) % Capacity,
std::memory_order_release
);
return item;
}
private:
std::array<T, Capacity> mBuffer;
std::atomic<size_t> mWritePos{0};
std::atomic<size_t> mReadPos{0};
};
Now you can use this in your audio engine to receive MIDI-like events:
struct NoteEvent {
enum class Type { NoteOn, NoteOff };
Type type;
int note;
float velocity;
};
class SynthEngine : public oboe::AudioStreamDataCallback {
LockFreeQueue<NoteEvent, 256> mEventQueue;
public:
// Called from UI thread
void noteOn(int note, float velocity) {
mEventQueue.push({NoteEvent::Type::NoteOn, note, velocity});
}
void noteOff(int note) {
mEventQueue.push({NoteEvent::Type::NoteOff, note, 0.0f});
}
oboe::DataCallbackResult onAudioReady(
oboe::AudioStream* stream,
void* audioData,
int32_t numFrames) override
{
// Process all pending events (non-blocking)
while (auto event = mEventQueue.pop()) {
if (event->type == NoteEvent::Type::NoteOn) {
startVoice(event->note, event->velocity);
} else {
stopVoice(event->note);
}
}
// Then render audio...
renderAudio(static_cast<float*>(audioData), numFrames);
return oboe::DataCallbackResult::Continue;
}
};
The std::optional return type from pop() makes the API clean and safe. No need for out-parameters or sentinel values.
The JNI Bridge: Connecting Kotlin to C++
Your audio engine lives in C++, but your UI is written in Kotlin. The bridge between them is JNI (Java Native Interface). The key rule is to keep this bridge thin. Pass simple types (floats, ints, booleans) and call JNI methods as infrequently as possible.
// jni_bridge.cpp
#include <jni.h>
#include "AudioEngine.h"
static std::unique_ptr<AudioEngine> sEngine;
extern "C" {
JNIEXPORT void JNICALL
Java_com_example_audio_AudioBridge_nativeStart(
JNIEnv* env, jobject thiz)
{
if (!sEngine) {
sEngine = std::make_unique<AudioEngine>();
}
sEngine->start();
}
JNIEXPORT void JNICALL
Java_com_example_audio_AudioBridge_nativeStop(
JNIEnv* env, jobject thiz)
{
if (sEngine) {
sEngine->stop();
}
}
JNIEXPORT void JNICALL
Java_com_example_audio_AudioBridge_nativeSetFrequency(
JNIEnv* env, jobject thiz, jfloat frequency)
{
if (sEngine) {
sEngine->setFrequency(frequency);
}
}
JNIEXPORT void JNICALL
Java_com_example_audio_AudioBridge_nativeSetGain(
JNIEnv* env, jobject thiz, jfloat gain)
{
if (sEngine) {
sEngine->setGain(gain);
}
}
} // extern "C"
And the Kotlin side:
class AudioBridge {
companion object {
init {
System.loadLibrary("audio-engine")
}
}
external fun nativeStart()
external fun nativeStop()
external fun nativeSetFrequency(frequency: Float)
external fun nativeSetGain(gain: Float)
}
Notice we use std::unique_ptr for the engine instance. It owns the engine exclusively and will clean it up if the app gets destroyed. This is RAII at work again.
What You Must Never Do in the Audio Callback
The audio callback runs on a real-time thread with a strict deadline. If you miss it, the user hears a glitch. Here is a quick reference of what is forbidden:
// NEVER do any of these inside onAudioReady():
void* ptr = malloc(1024); // Heap allocation is non-deterministic
delete someObject; // Deallocation too
mutex.lock(); // Can block indefinitely
std::string s = "hello"; // Allocates on the heap
std::vector<float> v(100); // Also heap allocation
file.read(buffer, size); // File I/O can block
env->CallVoidMethod(...); // JNI calls are not real-time safe
std::this_thread::sleep_for(...); // Obviously blocks
std::cout << "debug"; // I/O is blocking
Instead, pre-allocate everything before the stream starts and use std::atomic or lock-free queues to communicate with other threads.
Latency: What to Expect
Here are real-world round-trip latency numbers you can expect with different approaches:
| Approach | Round-Trip Latency |
|---|---|
| Oboe + C++ callback + Exclusive MMAP mode | 10 to 18 ms |
| Oboe + C++ callback + Shared mode | 14 to 50 ms |
| Java AudioTrack with low-latency mode | 40 to 100 ms |
| Java AudioTrack (default) | 100 to 250 ms |
That is a 5x to 25x improvement just by moving your audio processing to C++ with Oboe. The MWEngine open-source project documented this exact migration: their Java-based audio engine measured around 250ms latency, which dropped to 10 to 50ms after porting to C++.
NDK Setup: Which C++ Standard to Use
The Android NDK uses LLVM’s libc++ and supports C++17 by default. If you want to use std::span, concepts, or coroutines, you need to opt into C++20:
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
One thing to keep in mind: if your app targets Android 15 (API 35) and you plan to publish after November 2025, you need NDK r28 or later to support 16 KB page sizes. This is a Google Play requirement for all new apps with native code. The good news is that rebuilding with NDK r28+ is usually all you need to do.
Putting It All Together
Let us recap the architecture. Your app has two layers:
Kotlin/Java layer handles the UI, permissions, and activity lifecycle. It communicates with the C++ layer through a thin JNI bridge, passing simple values like floats and ints.
C++ layer owns the audio engine entirely. It uses Oboe to open a low-latency stream, processes audio in a real-time callback, and communicates with the Kotlin layer through std::atomic values and lock-free queues.
The modern C++ features we covered each solve a specific problem:
- constexpr eliminates runtime computation of audio constants
- Smart pointers and RAII guarantee deterministic cleanup of audio resources
- std::atomic enables lock-free parameter sharing between threads
- std::span provides zero-cost, type-safe buffer access
- std::optional makes lock-free queue APIs clean and explicit
- Templates let you build reusable DSP components without runtime overhead
If you are building anything that touches audio on Android, whether it is a synth, a DAW, a podcast recorder, or a game with sound effects, C++ is not optional. It is the only way to get the performance and determinism that real-time audio demands.

Loading comments…