nathanrenting.dev
Pattern · Audio DSP

Echtzeit-Audio in Rust

Audio-Callbacks haben eine harte Deadline. Bei 48kHz mit einem 256-Sample-Buffer bleiben dem Audio-Thread ~5 Millisekunden, um den nächsten Chunk zu produzieren. Verpasst du das, hört der Nutzer ein Klicken, eine Lücke oder einen Dropout. Diese Einschränkung prägt alles, was darunter passiert.

Handgezeichnete Skizze der Audio-Engine: Der JS/UI-Thread schickt Control-Messages über einen lock-free Ring-Buffer (rtrb) an den Audio-Callback, der allokationsfrei innerhalb der 5ms-Deadline läuft und Samples mit 48kHz an AAudio/CoreAudio ausgibt.

Whiteboard-Skizze · die Form der Engine

Rust passt gut zu dieser Arbeit — sobald du den Großteil der Standard Library liegen lässt. Der Borrow Checker verhindert Data Races zur Compile-Zeit, aber ein einziges Box::new auf dem Audio-Pfad ruiniert dir trotzdem die Latenz. Die folgenden Patterns sind das, was es in der Praxis zum Laufen bringt.

Das Setup

Eine typische mobile Audio-App hat drei Threads, die zählen:

  1. JS- / UI-Thread — fängt User-Input ab, schickt Control-Messages
  2. Native Dispatch-Thread — übersetzt diese Messages, führt die Business-Logik aus, hält den State
  3. Audio-Callback-Thread — läuft im OS-Audio-Subsystem (AAudio auf Android, CoreAudio auf iOS), wird mit striktem Timing aufgerufen

Der Audio-Callback-Thread darf niemals blockieren, niemals allokieren, niemals auf einen Mutex warten. Wenn der JS-Thread einen Parameter ändert (Lautstärke, FX-Bypass, was auch immer), muss diese Änderung den Audio-Thread erreichen, ohne dass der Audio-Thread jemals warten muss.

Das ist das Kernproblem. Der lock-free SPSC-Ring-Buffer ist die Lösung.

Lock-free SPSC via rtrb

Ein Single-Producer-, Single-Consumer-Ring-Buffer ist die einfachste lock-free Datenstruktur, die hier funktioniert. Ein Thread schreibt, ein anderer liest, keiner blockiert. Die Rust-Crate rtrb (real-time ring buffer) implementiert das mit der richtigen Atomic-Semantik:

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

// Setup (einmal, außerhalb des Audio-Threads)
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, leere alles, was in der Queue steht
while let Ok(msg) = consumer.pop() {
    apply_message(&mut state, msg);
}

Der Audio-Callback leert den Buffer zu Beginn jedes Blocks, wendet die Control-Änderungen an und verarbeitet dann Audio. Keine Locks, kein Warten, keine Allokationen.

Wenn die Producer-Seite schneller ist, als der Consumer leert, läuft der Buffer voll und push gibt einen Error zurück. Genau das ist dein Signal: Entweder ist der Buffer zu klein, oder der Consumer dropt Frames. Beides fängst du zur Laufzeit ab; keines davon korrumpiert das Audio.

Allokationsfreier Audio-Pfad

Die andere Disziplin ist schwerer durchzusetzen: null Heap-Allokationen während Audio-Callbacks. Das bedeutet:

Das Pattern: alles beim Setup vorab allokieren, Buffer auf dem Audio-Thread wiederverwenden. Ein Vec<f32> mit der Kapazität samples_per_block, einmal vor dem Start der Audio-Engine allokiert, der bei jedem Callback in-place überschrieben wird.

Rust's Ownership-Modell hilft hier — sobald du das Pattern von „Code ohne Box-/Vec-/String-Allokationen" einmal verinnerlicht hast, hält dich der Compiler ehrlich. Das Pattern überträgt sich von Projekt zu Projekt.

FFI-Oberfläche

Die Audio-Engine kompiliert zu einer statischen Library. Die App (in meinem Fall React Native) ruft die Engine über eine FFI-Schicht auf. Zwei Design-Entscheidungen, die zählen:

1. Halte die FFI-Oberfläche klein. Jede FFI-Funktion ist eine Wartungslast — beide Sprachen müssen sich über Memory-Layout, Lifetime und Error-Handling einig sein. Ich ziele auf ~10-15 FFI-Funktionen ab, die die öffentliche API der Engine wrappen; alles darüber hinaus bleibt intern auf der Rust-Seite.

2. Gib Opaque Pointer weiter. Versuche nicht, Rust-Structs über das C-ABI zu teilen. Allokiere das Struct in Rust, gib einen *mut OpaqueState-Pointer zurück und lass jeden folgenden FFI-Call diesen Pointer weiterreichen. Der Caller schaut nie hinein; die Rust-Seite besitzt das Layout. Cleanup ist ein einziger drop_state(*mut OpaqueState)-FFI-Call.

Cross-Platform-Realität

Android AAudio verhält sich anders als iOS CoreAudio. Buffer-Größen, die auf einem Gerät funktionieren, funktionieren auf einem anderen nicht. OEM-Android-Builds (Samsung, OnePlus, Xiaomi) haben jeweils ihre eigenen Edge-Cases. Die praktische Antwort:

Eine Audio-Engine, die für sich genommen korrekt ist, funktioniert noch nicht auf jedem Gerät. Eine echte Testbench ist wichtiger als synthetische Benchmarks.

Was das möglich macht

Sobald der Audio-Pfad allokationsfrei und der Control-Pfad lock-free ist, kannst du Layer aufbauen: Multi-Track-Playback, FX-Chains pro Track, sample-genaues Scheduling, MIDI-Steuerung. Die Grunddisziplin wird nicht schwerer, du bekommst nur mehr Nodes im Graphen.

Die Latenz bleibt unter 20ms auf Consumer-Android-Hardware. Das ist die Grenze, unter der sich die meisten Menschen etwas „instant" anfühlt. Über 20ms spürst du den Lag. Unter 20ms trittst du beim Grundgefühl gegen Desktop-DAWs an.