Skip to content
Rust & Native

Low-Latency Audio DSP with Rust: AAudio and Oboe from a Rust Core

By David Cruz Anaya, Senior Mobile & Applied AI Engineer (Android, Kotlin Multiplatform, product AI)

Updated 25 September 202610 min read
AndroidRustNDKAudioAAudioDSP
Low-Latency Audio DSP with Rust: AAudio and Oboe from a Rust Core
Share:

Rust + NDK on Android, Part 10 of 15.This series teaches Android developers how to ship Rust in production apps, no prior Rust required. Hub: Rust on Android: How to Use It, How to Debug It, and Why It's Worth It. Previous: Threading and Async: Tokio, Kotlin Coroutines, and Calling Back Into Kotlin from Rust. Next: Heavy Parsing with Serde: Offloading JSON and CSV Crunching to Rust.

You can hold a real-time audio deadline on Android from pure Rust, with no C++ in the build. AAudio, the platform’s low latency audio API since Android 8.0, is a plain C API (Android Developers, AAudio guide), and the ndk crate wraps it in safe Rust, callback included. In this part you’ll open a low latency output stream from a Rust cdylib and render a synth through a gain and filter chain that never allocates. A Kotlin slider drives the gain and the filter cutoff while it plays, and nothing on either side takes a lock.

This is the Rust counterpart to my modern C++ audio guide. The rules of the audio thread do not change with the language; what changes is how many of them the compiler enforces. It assumes the toolchain from Part 1, the handle pattern from Part 7, and the threading rules from Part 9.

Key Takeaways

  • A Rust cdylib can drive AAudio directly through the ndk crate’s audio module. minSdk must be 26 or higher, because the crate links libaaudio.so.
  • The callback contract is the one Google documents for Oboe: no allocation, no locks, no I/O, which rules out JNI as well. Rust checks the sharing rules at compile time; allocation stays your discipline.
  • Parameters cross to the audio thread as f32 bit patterns in an AtomicU32 with Relaxed ordering. No mutex, no torn reads, no priority inversion.
  • Oboe still earns its place below minSdk 26 or when you want its per-device workarounds. The oboe crate wraps the C++ library for Rust.

Why Rust fits low latency audio on Android

An audio callback is the hardest deadline most app code ever meets. The stream calls you every few milliseconds, you fill a buffer, and if you are late the user hears a click. Google’s rules for that callback are explicit: no memory allocation, no file or network I/O, no mutexes or other synchronization primitives, no sleeping, no closing the stream from inside it (Google’s Oboe Full Guide). JNI fails several of those at once, which is why the audio thread never touches Kotlin, a rule Part 9 established.

In C++ that list lives in code review. In Rust, a useful chunk of it moves into the type system. The ndk crate’s data callback is a boxed FnMut closure that must be Send, so state moved into it must prove it can cross threads, and anything shared with the UI thread must be Sync, which pushes you straight to atomics and lock-free queues. The stream handle is !Send, so the compiler refuses to let you stash it in a global. And the data race that corrupts one buffer a week on one device model, the bug that pushed me from C++ toward Rust, cannot be written in safe Rust.

Be honest about the other half. Vec::push in the callback compiles without a warning, and so does Mutex::lock. Rust has no garbage collector, which removes the JVM’s class of glitches, but the allocator is still non-deterministic and still forbidden. The compiler shrinks the list you enforce by discipline; it does not empty it.

Opening an AAudio stream from Rust

The crate needs jni for the boundary and ndk with its audio feature for AAudio. This is rust/audiofx/Cargo.toml in full:

[package]
name = "audiofx"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
jni = "0.21"
ndk = { version = "0.9", features = ["audio"] }

The audio feature links libaaudio.so, which exists on API 26 and later, so set minSdk = 26. Below that, skip ahead to the Oboe section.

Everything else lives in rust/audiofx/src/lib.rs, starting with imports, constants, and the stream setup:

use std::f32::consts::PI;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;

use jni::objects::JClass;
use jni::sys::{jfloat, jlong};
use jni::JNIEnv;
use ndk::audio::{
    AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode,
    AudioSharingMode, AudioStream, AudioStreamBuilder,
};

const SAMPLE_RATE: i32 = 48_000;
const CHANNELS: i32 = 2;
const FREQUENCY_HZ: f32 = 220.0;

/// Owns the stream and the parameters shared with the audio thread.
pub struct Engine {
    stream: AudioStream,
    params: Arc<SharedParams>,
}

fn start_engine() -> ndk::audio::Result<Engine> {
    let params = Arc::new(SharedParams::new());
    let mut dsp = Dsp {
        phase: 0.0,
        lp_state: 0.0,
        params: Arc::clone(&params),
    };

    let stream = AudioStreamBuilder::new()?
        .direction(AudioDirection::Output)
        .format(AudioFormat::PCM_Float)
        .channel_count(CHANNELS)
        .sample_rate(SAMPLE_RATE)
        .performance_mode(AudioPerformanceMode::LowLatency)
        .sharing_mode(AudioSharingMode::Exclusive)
        .data_callback(Box::new(move |_stream, data, num_frames| {
            // SAFETY: AAudio hands us num_frames frames in the format
            // and channel count we requested: interleaved f32 samples.
            let samples = unsafe {
                std::slice::from_raw_parts_mut(
                    data.cast::<f32>(),
                    (num_frames * CHANNELS) as usize,
                )
            };
            dsp.render(samples);
            AudioCallbackResult::Continue
        }))
        .open_stream()?;

    stream.request_start()?;
    Ok(Engine { stream, params })
}

Three settings do the latency work. LowLatency asks for smaller buffers and the optimized data path, Exclusive asks for sole access to the device, and the callback lets the platform drive the timing. The AAudio guide ties the lowest latency to the LowLatency mode with a callback, and calls exclusive streams likely to have lower latency but more likely to get disconnected (Android Developers, AAudio guide).

Two production notes. The builder is a request, not a promise: the device may hand you a shared stream or a different rate, so real code reads stream.sample_rate() after opening, instead of trusting my SAMPLE_RATE constant, and recomputes whatever depends on it. And streams die in normal use, headphones unplug, so register an error_callback and rebuild when it fires.

A DSP chain that never allocates

The synth is deliberately small: a naive sawtooth into a one-pole low-pass into a gain stage. A saw rather than a sine because harmonics let you hear the filter move. The Dsp struct and its render function, also in rust/audiofx/src/lib.rs:

/// All DSP state, allocated once before the stream starts and
/// moved into the audio callback.
struct Dsp {
    phase: f32,
    lp_state: f32,
    params: Arc<SharedParams>,
}

impl Dsp {
    fn render(&mut self, samples: &mut [f32]) {
        let gain = SharedParams::load(&self.params.gain);
        let cutoff = SharedParams::load(&self.params.cutoff_hz);

        // Plain arithmetic only: no allocation, no locks, no syscalls.
        let dt = 1.0 / SAMPLE_RATE as f32;
        let rc = 1.0 / (2.0 * PI * cutoff);
        let alpha = dt / (rc + dt);
        let step = FREQUENCY_HZ / SAMPLE_RATE as f32;

        for frame in samples.chunks_exact_mut(CHANNELS as usize) {
            // Naive sawtooth in [-1.0, 1.0]. It aliases; fine for a demo.
            let raw = 2.0 * self.phase - 1.0;
            self.phase += step;
            if self.phase >= 1.0 {
                self.phase -= 1.0;
            }
            // One-pole low-pass, then gain.
            self.lp_state += alpha * (raw - self.lp_state);
            let sample = self.lp_state * gain;
            for channel_sample in frame.iter_mut() {
                *channel_sample = sample;
            }
        }
    }
}

Everything the callback needs was allocated before the stream started: oscillator phase, filter state, and the Arc to the shared parameters live inside Dsp, moved into the closure, moved into the stream. From first callback to last, render touches nothing but its own fields and the buffer AAudio handed it, with chunks_exact_mut walking the interleaved stereo frames.

The quiet win is what the signature forbids. Because the callback must be Send, the compiler checks every field of Dsp for thread-crossing safety. Swap the Arc for an Rc, or sneak in a JNIEnv, and the crate stops compiling with an error naming the field. In C++ that mistake ships, then glitches on a Tuesday.

Passing parameters without locks

The user drags a slider on the main thread; the audio thread needs the value a few milliseconds later. A Mutex is the obvious answer and the forbidden one: if the audio thread waits on a lock held by a lower-priority thread, the deadline is gone. The C++ post used std::atomic<float>. Rust has no stable AtomicF32, so we store the float’s bits in an AtomicU32. The sharing side of rust/audiofx/src/lib.rs:

/// Parameters shared between the UI thread and the audio thread.
/// Each f32 is stored as its raw bits in an AtomicU32, so the
/// audio thread reads them without ever taking a lock.
pub struct SharedParams {
    gain: AtomicU32,
    cutoff_hz: AtomicU32,
}

impl SharedParams {
    fn new() -> Self {
        Self {
            gain: AtomicU32::new(0.5f32.to_bits()),
            cutoff_hz: AtomicU32::new(2_000.0f32.to_bits()),
        }
    }

    fn store(cell: &AtomicU32, value: f32) {
        cell.store(value.to_bits(), Ordering::Relaxed);
    }

    fn load(cell: &AtomicU32) -> f32 {
        f32::from_bits(cell.load(Ordering::Relaxed))
    }
}

Ordering::Relaxed is not me cutting a corner. Each parameter has one writer, the UI thread, and one reader, the callback. The reader tolerates a value one buffer stale, and no other memory operation depends on the order of these stores. What Relaxed still guarantees is that every read is a complete 32-bit value: a torn float is impossible. That is the entire requirement, so anything stronger buys nothing.

Single values only get you so far. Events, note on and note off, need a queue lock-free at both ends: the SPSC ring buffer the C++ post builds by hand around two atomic indices. In Rust you do not build it. The rtrb crate is a realtime-safe SPSC ring buffer that never allocates after construction; this synth does not need one, so it stays the named next step.

Wiring the Kotlin slider

The boundary uses the raw-pointer handle pattern from Part 7: nativeStart boxes the engine and hands Kotlin the address as a Long, the setters borrow it, and nativeStop reclaims and drops it, closing the stream. There is a Rust reason for it: AudioStream is !Send, so the compiler rejects parking the engine in a static. The handle keeps ownership on the main thread; the audio thread only ever sees the atomics inside the Arc. These functions close out rust/audiofx/src/lib.rs:

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_AudioFx_nativeStart(
    _env: JNIEnv,
    _class: JClass,
) -> jlong {
    match start_engine() {
        Ok(engine) => Box::into_raw(Box::new(engine)) as jlong,
        Err(_) => 0,
    }
}

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_AudioFx_nativeSetGain(
    _env: JNIEnv,
    _class: JClass,
    handle: jlong,
    gain: jfloat,
) {
    if handle == 0 {
        return;
    }
    let engine = unsafe { &*(handle as *const Engine) };
    SharedParams::store(&engine.params.gain, gain.clamp(0.0, 1.0));
}

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_AudioFx_nativeSetCutoff(
    _env: JNIEnv,
    _class: JClass,
    handle: jlong,
    cutoff_hz: jfloat,
) {
    if handle == 0 {
        return;
    }
    let engine = unsafe { &*(handle as *const Engine) };
    SharedParams::store(&engine.params.cutoff_hz, cutoff_hz.clamp(40.0, 8_000.0));
}

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_AudioFx_nativeStop(
    _env: JNIEnv,
    _class: JClass,
    handle: jlong,
) {
    if handle == 0 {
        return;
    }
    let engine = unsafe { Box::from_raw(handle as *mut Engine) };
    let _ = engine.stream.request_stop();
    // Dropping the engine closes the stream.
}

The Kotlin bridge is four externals in app/src/main/java/dev/davthecoder/rustseries/AudioFx.kt:

package dev.davthecoder.rustseries

object AudioFx {
    init {
        System.loadLibrary("audiofx")
    }

    external fun nativeStart(): Long
    external fun nativeStop(handle: Long)
    external fun nativeSetGain(handle: Long, gain: Float)
    external fun nativeSetCutoff(handle: Long, cutoffHz: Float)
}

The whole UI is one Compose screen in app/src/main/java/dev/davthecoder/rustseries/SynthScreen.kt:

package dev.davthecoder.rustseries

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun SynthScreen() {
    var handle by remember { mutableLongStateOf(0L) }
    var gain by remember { mutableFloatStateOf(0.5f) }
    var cutoff by remember { mutableFloatStateOf(2_000f) }

    DisposableEffect(Unit) {
        onDispose {
            if (handle != 0L) AudioFx.nativeStop(handle)
        }
    }

    Column(modifier = Modifier.padding(24.dp)) {
        Button(onClick = {
            handle = if (handle == 0L) {
                AudioFx.nativeStart()
            } else {
                AudioFx.nativeStop(handle)
                0L
            }
        }) {
            Text(if (handle == 0L) "Start" else "Stop")
        }

        Text("Gain")
        Slider(
            value = gain,
            onValueChange = {
                gain = it
                if (handle != 0L) AudioFx.nativeSetGain(handle, it)
            },
        )

        Text("Cutoff: ${cutoff.toInt()} Hz")
        Slider(
            value = cutoff,
            valueRange = 40f..8_000f,
            onValueChange = {
                cutoff = it
                if (handle != 0L) AudioFx.nativeSetCutoff(handle, it)
            },
        )
    }
}

Drag the cutoff slider while the saw plays and the filter sweeps cleanly: every slider event is one atomic store, picked up on the callback’s next pass. The DisposableEffect closes the stream when the screen leaves composition, so a rotation does not leak a running engine, the failure mode Part 7 covers. If the tone should survive rotation, hoist the handle into a ViewModel and call nativeStop from onCleared instead.

I left latency numbers out on purpose. They depend on device, sharing mode, and whether the exclusive request was granted; the measured expectations in my C++ audio guide carry over, because both paths sit on the same AAudio streams. When the companion repo ships this module, real measurements land here.

Where Oboe still fits

Oboe is Google’s open source C++ library above AAudio and OpenSL ES: it uses AAudio on API 27 and later and falls back to OpenSL ES below that (Oboe, Android Developers). Two situations justify it over raw AAudio from Rust: a minSdk below 26, where AAudio does not exist, and its workaround catalog, years of fixes for device-specific audio bugs that raw AAudio callers rediscover one report at a time.

From Rust, the oboe crate, at 0.6 as of mid-2026, wraps the C++ library in a safe interface and is the Android backend of cpal, Rust’s standard cross-platform audio library. The trade is your build: it compiles Oboe’s C++ sources, so the C++ toolchain returns as a build dependency. My default is the pure ndk path from this post, with the oboe crate when an older minSdk or a specific device bug forces it.

I say that having shipped the other answer. Klarinet, my Kotlin Multiplatform audio SDK, runs its effects chain in a C++ core on Oboe, and that core does its job. But the bugs that cost me nights were exactly the ones the Rust compiler catches: state shared with the callback that was less thread-safe than code review believed. If I started that engine today, the DSP core would be Rust.

Frequently Asked Questions

Can you do low latency audio on Android in pure Rust?

Yes. AAudio has been a plain C API since Android 8.0, and the ndk crate wraps the stream builder and data callback in safe Rust, so a cdylib renders audio with no C++ in the build. The latency class matches C++ because both paths sit on the same AAudio streams.

Do you need Oboe, or is AAudio enough?

AAudio is enough when minSdk is 26 or higher, the common case in 2026. Oboe matters for older devices through its OpenSL ES fallback, or for its per-device bug workarounds. From Rust, the oboe crate wraps the C++ library at the cost of a C++ toolchain in the build.

Why can’t the audio callback call back into Kotlin?

A JNI call can allocate, take locks inside the JVM, and block, all forbidden on the audio thread. Move data with atomics or a lock-free ring buffer and let the Kotlin side observe from a normal thread.

Where this leaves you

The synth is small, but the architecture is the one real Rust audio engines ship: state owned by the callback, parameters crossing as atomics, a raw handle at the JNI border, and a stream the compiler will not let you misuse across threads. Swap the saw for your oscillator bank and the one-pole for your filter stack; the shape holds.

Part 11 turns to a quieter performance problem: multi-megabyte JSON and CSV payloads that stutter the main thread when parsed in Kotlin. Part 11, Heavy Parsing with Serde: Offloading JSON and CSV Crunching to Rust, puts the chunky-call principle to work. The full series map lives at the hub.

Sources

Share:

Comments

Loading comments…