Why C++ for Android Audio?
A Java or Kotlin audio path on Android picks up clicks, pops, and a delay of 100 to 250 milliseconds between a touch and the sound. For a musical instrument app, that delay is 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 writes:
“Do not do any audio processing in Java. Keep all audio processing in C++.”
With C++, you get deterministic execution, direct memory control, and a high-priority audio thread without garbage collection. 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.
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.
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
RAII (Resource Acquisition Is Initialization) means an object acquires a resource when you create it and releases that resource when it goes out of scope. No manual cleanup.
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;
};
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.
constexpr computes them at compile time:
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. Your callback has roughly 4 milliseconds to fill a buffer.
std::atomic: Lock-Free Parameter Updates
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. 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 need each individual read/write to be atomic. This compiles down to a single load instruction on ARM.
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, with 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 use a gsl::span from the Guidelines Support Library.
Lock-Free Ring Buffer: Passing Events Between Threads
Atomics cover single values. For a stream of events from the UI thread to the audio thread, such as MIDI note-on/note-off messages or parameter automation, 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() replaces out-parameters and 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)
}
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 difference between the default Java path and Oboe with a C++ callback. 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. Rebuilding with NDK r28+ is usually all you need to do.
Resources
¿Por qué C++ para el audio en Android?
Una ruta de audio en Java o Kotlin en Android recoge clics, chasquidos y un retardo de 100 a 250 milisegundos entre el toque y el sonido. Para una aplicación de instrumento musical, ese retardo es inutilizable.
La causa de fondo no es tu código. Es la propia JVM. El recolector de basura de Java puede pausar tu hilo en cualquier momento y, cuando tu callback de audio tiene que entregar muestras cada 5 o 10 milisegundos, hasta una pausa breve del GC provoca un fallo audible. El equipo de audio de Android en Google escribe:
«No hagas ningún procesado de audio en Java. Mantén todo el procesado de audio en C++.»
Con C++ obtienes ejecución determinista, control directo de la memoria y un hilo de audio de alta prioridad sin recolección de basura. Si todavía estás decidiendo si el código nativo tiene sitio en tu aplicación, recorro esa decisión en cuándo y por qué usar C++ en Android.
Las herramientas: AAudio y Oboe
Android tiene dos API de audio en C++ que conviene conocer. AAudio es la API nativa de baja latencia que llegó con Android 8.0 (API 26). Habla directamente con el hardware de audio a través de búferes proyectados en memoria, y así es como consigues esa latencia por debajo de 20 ms.
El problema de AAudio es que solo funciona en Android 8.0 y posteriores. Si tienes que dar soporte a dispositivos más antiguos, también tendrías que escribir un respaldo en OpenSL ES. Eso es mucho código repetitivo.
Oboe es la biblioteca de C++ de código abierto de Google que envuelve tanto AAudio como OpenSL ES. En Android 8.1 y posteriores usa AAudio. En dispositivos más antiguos recurre a OpenSL ES automáticamente. También corrige fallos conocidos de la plataforma, así que no tienes que lidiar con las peculiaridades de cada dispositivo.
Para añadir Oboe a tu proyecto, actualiza tu 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
)
Y en tu build.gradle.kts:
android {
defaultConfig {
externalNativeBuild {
cmake {
arguments("-DANDROID_STL=c++_shared")
}
}
}
buildFeatures {
prefab = true
}
}
dependencies {
implementation("com.google.oboe:oboe:1.9.0")
}
Montar el motor de audio con RAII
RAII (Resource Acquisition Is Initialization) significa que un objeto adquiere un recurso cuando lo creas y suelta ese recurso cuando sale de ámbito. No hace falta limpieza manual.
Aquí tienes una clase básica de motor de audio que gestiona un flujo de Oboe con punteros inteligentes:
#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;
};
El destructor llama a stop(), así que el flujo siempre se limpia cuando se destruye el motor. La clase no se puede copiar porque duplicar un flujo de audio no tiene sentido. Y usamos std::shared_ptr para el flujo, que es la API que Oboe recomienda desde la versión 1.7.
constexpr: constantes de audio a coste cero
El código de audio está lleno de constantes: tasas de muestreo, tamaños de búfer, longitudes de tablas de ondas, valores matemáticos. En un callback de audio en tiempo real que se ejecuta cientos de veces por segundo, quieres que se calculen en tiempo de compilación, no en tiempo de ejecución.
constexpr los calcula en tiempo de compilación:
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);
El compilador evalúa phaseIncrement(440.0f) en tiempo de compilación. En tiempo de ejecución es solo una constante quieta en tu binario. Ni división, ni multiplicación, ni llamada a función. Tu callback tiene unos 4 milisegundos para rellenar un búfer.
std::atomic: actualizaciones de parámetros sin bloqueos
Quien usa la aplicación arrastra un deslizador en el hilo de la interfaz para cambiar el volumen. Mientras tanto, el callback de audio corre en un hilo aparte de alta prioridad y lee ese valor de volumen cientos de veces por segundo. ¿Cómo compartes este valor con seguridad?
No puedes usar un mutex. Si el hilo de audio se bloquea esperando un cerrojo que tiene el hilo de la interfaz, tienes un fallo de audio. No bloquees nunca el hilo de audio.
La solución es 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;
}
};
Usamos memory_order_relaxed porque no necesitamos garantías de orden entre variables atómicas distintas. Necesitamos que cada lectura y cada escritura individual sea atómica. Esto se compila a una sola instrucción de carga en ARM.
std::span: acceso seguro al búfer sin sobrecoste
El callback de audio te da un puntero crudo void* y un recuento de frames. Esto es territorio clásico de C, y es fácil equivocarse: escribir más allá del final del búfer, equivocarte en el número de canales o hacer un cast al tipo que no es.
El std::span de C++20 envuelve ese puntero crudo en una vista segura y a coste cero. Ni copias, ni asignaciones, con seguridad de tipos y comprobación de límites en las compilaciones de depuración:
#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;
}
Si estás atascado en C++17 (que es el valor por defecto del NDK), puedes usar un span ligero retroportado o un gsl::span de la Guidelines Support Library.
Búfer circular sin bloqueos: pasar eventos entre hilos
Los atómicos cubren valores sueltos. Para un flujo de eventos del hilo de la interfaz al hilo de audio, como mensajes MIDI de note-on y note-off o automatización de parámetros, necesitas un búfer circular sin bloqueos de un solo productor y un solo consumidor (SPSC). El hilo de la interfaz mete eventos y el callback de audio los saca, todo sin un solo 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};
};
Ahora puedes usarlo en tu motor de audio para recibir eventos al estilo MIDI:
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;
}
};
El tipo de retorno std::optional de pop() sustituye los parámetros de salida y los valores centinela.
El puente JNI: conectar Kotlin con C++
Tu motor de audio vive en C++, pero tu interfaz está escrita en Kotlin. El puente entre los dos es JNI (Java Native Interface). La regla clave es mantener este puente fino. Pasa tipos simples (floats, ints, booleanos) y llama a métodos JNI tan pocas veces como sea posible.
// 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"
Y el lado de Kotlin:
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)
}
Usamos std::unique_ptr para la instancia del motor. Tiene la propiedad exclusiva del motor y lo libera si la aplicación se destruye. RAII otra vez, en marcha.
Lo que no debes hacer nunca en el callback de audio
El callback de audio corre en un hilo de tiempo real con un plazo estricto. Si no llegas, quien escucha oye un fallo. Aquí tienes una referencia rápida de lo que está prohibido:
// 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
En su lugar, reserva todo antes de que arranque el flujo y usa std::atomic o colas sin bloqueos para hablar con otros hilos.
Latencia: qué puedes esperar
Aquí tienes cifras reales de latencia de ida y vuelta que puedes esperar con distintos enfoques:
| Enfoque | Latencia de ida y vuelta |
|---|---|
| Oboe + callback de C++ + modo Exclusive MMAP | de 10 a 18 ms |
| Oboe + callback de C++ + modo compartido | de 14 a 50 ms |
| AudioTrack de Java con modo de baja latencia | de 40 a 100 ms |
| AudioTrack de Java (por defecto) | de 100 a 250 ms |
Eso es una diferencia de 5× a 25× entre la ruta Java por defecto y Oboe con un callback de C++. El proyecto de código abierto MWEngine documentó exactamente esta migración: su motor de audio en Java medía unos 250 ms de latencia, que bajaron a entre 10 y 50 ms tras portarlo a C++.
Configuración del NDK: qué estándar de C++ usar
El NDK de Android usa la libc++ de LLVM y admite C++17 por defecto. Si quieres usar std::span, concepts o corrutinas, tienes que optar por C++20:
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
Una cosa que conviene tener en cuenta: si tu aplicación apunta a Android 15 (API 35) y piensas publicar después de noviembre de 2025, necesitas el NDK r28 o posterior para admitir tamaños de página de 16 KB. Es un requisito de Google Play para todas las aplicaciones nuevas con código nativo. Recompilar con NDK r28 o posterior suele ser todo lo que tienes que hacer.

Loading comments…Cargando comentarios…