nathanrenting.dev
Pattern · Audio DSP

Real-time audio in Rust

Audio callbacks hebben een harde deadline. Op 48kHz met een 256-sample buffer krijgt de audio-thread ~5 milliseconden om de volgende chunk te produceren. Mis je dat, dan hoort de gebruiker een klik, een gap, of een dropout. Die constraint vormt alles wat eronder gebeurt.

Hand-getekende schets van de audio engine: JS/UI thread stuurt control-msg via een lock-free ring buffer (rtrb) naar de audio callback, die allocation-free draait binnen de 5ms deadline en samples op 48kHz uit naar AAudio/CoreAudio.

Whiteboard-schets · de vorm van de engine

Rust past goed bij dit werk — zodra je het grootste deel van de standard library laat liggen. De borrow checker voorkomt data-races op compile-time, maar één Box::new op het audio-pad sloopt je latency alsnog. De patronen hieronder zijn wat het in de praktijk laat werken.

De setup

Een typische mobile audio-app heeft drie threads die ertoe doen:

  1. JS / UI thread — vangt user-input op, stuurt control-messages
  2. Native dispatch thread — vertaalt die messages, runt business-logic, houdt state bij
  3. Audio callback thread — draait in het OS-audio-subsystem (AAudio op Android, CoreAudio op iOS), wordt aangeroepen met strikte timing

De audio-callback-thread mag nooit blokkeren, nooit alloceren, nooit wachten op een mutex. Als de JS-thread een parameter aanpast (volume, FX bypass, wat dan ook), moet die wijziging naar de audio-thread zonder dat de audio-thread ooit hoeft te wachten.

Dat is het kernprobleem. De lock-free SPSC ring-buffer is de oplossing.

Lock-free SPSC via rtrb

Een single-producer, single-consumer ring-buffer is de simpelste lock-free data-structuur die hier werkt. Eén thread schrijft, een ander leest, geen van beide blokkeert. De Rust-crate rtrb (real-time ring buffer) implementeert dit met de juiste atomic-semantics:

use rtrb::{Consumer, Producer, RingBuffer};

// Setup (één keer, buiten de audio-thread)
let (mut producer, mut consumer): (Producer<ControlMsg>, Consumer<ControlMsg>) =
    RingBuffer::new(256).split();

// JS / dispatch thread — non-blocking push
producer.push(ControlMsg::SetVolume { track: 0, value: 0.8 })
    .expect("ring buffer full");

// Audio callback — non-blocking pop, leeg alles wat in de queue staat
while let Ok(msg) = consumer.pop() {
    apply_message(&mut state, msg);
}

De audio-callback leegt de buffer aan het begin van elk block, past de control-changes toe, en verwerkt dan audio. Geen locks, geen waits, geen allocaties.

Als de producer-kant sneller is dan de consumer drained, raakt de buffer vol en geeft push een error. Dat is precies je signal: óf de buffer is te klein, óf de consumer dropt frames. Beide vang je op runtime; geen van beide corrupted de audio.

Allocation-vrije audio-path

De andere discipline is lastiger om af te dwingen: nul heap-allocaties tijdens audio-callbacks. Dat betekent:

Het patroon: alles pre-alloceren tijdens setup, buffers hergebruiken op de audio-thread. Een Vec<f32> van samples_per_block capaciteit, één keer geallocate vóór de audio-engine start, die elke callback in-place overschreven wordt.

Rust's ownership-model helpt hier — zodra je het patroon van "code-zonder-Box/Vec/String-allocaties" eenmaal te pakken hebt, houdt de compiler je eerlijk. Het patroon draagt over van project naar project.

FFI-oppervlak

De audio-engine compileert naar een static library. De app (in mijn geval React Native) calls naar de engine via een FFI-laag. Twee design- keuzes die ertoe doen:

1. Hou het FFI-oppervlak klein. Elke FFI-functie is een onderhoudslast — beide talen moeten het eens zijn over memory-layout, lifetime, error-handling. Ik mik op ~10-15 FFI-functies die de publieke API van de engine wrappen; alles wat verder gaat blijft intern aan de Rust-kant.

2. Geef opaque pointers door. Probeer geen Rust-structs te delen via C-ABI. Alloceer de struct in Rust, geef een *mut OpaqueState-pointer terug, en laat alle volgende FFI-calls die pointer doorgeven. De caller kijkt er nooit in; de Rust-kant bezit de layout. Cleanup is één drop_state(*mut OpaqueState) FFI-call.

Cross-platform realiteit

Android AAudio gedraagt zich anders dan iOS CoreAudio. Buffer-sizes die werken op het ene device werken niet op het andere. OEM-Android-builds (Samsung, OnePlus, Xiaomi) hebben elk hun edge-cases. Het praktische antwoord:

De audio-engine die op zichzelf correct is, werkt nog niet op elk device. Een echte testbench is belangrijker dan synthetische benchmarks.

Wat dit mogelijk maakt

Zodra het audio-pad allocation-vrij is en het control-pad lock-free, kun je layer opbouwen: multi-track playback, per-track FX-chains, sample-accurate scheduling, MIDI-control. De basis-discipline wordt niet zwaarder, je krijgt alleen meer nodes in de graph.

Latency blijft sub-20ms op consumer-Android-hardware. Dat is de grens waaronder de meeste mensen "instant" voelen. Boven 20ms voel je de lag. Onder 20ms ga je de strijd aan met desktop-DAWs op het basis-gevoel.