Building a Cross-Platform DAW Engine from Scratch — Rust × Flutter
Published on
·7 min read

Building a Cross-Platform DAW Engine from Scratch — Rust × Flutter

Authors
  • avatar
    Name
    Bert / DOTUNE
    Developer

We built a DAW (Digital Audio Workstation) that runs on a phone. A full music workstation — multi-track editing, MIDI sequencing, SoundFont synthesis, real-time recording, mixing, and export — on iOS, Android, Web, and Desktop.

This is the technical postmortem: the stack decisions, the engine architecture, and the three hardest problems in real-time audio.


Why Build a DAW Engine from Scratch

The requirement was clear: a mobile-first, cross-platform audio app. Not a player — a full workstation. Users edit MIDI, load SoundFonts, record audio, mix, and open the same project file on iOS, Android, Web, and Desktop.

No existing solution ticked all the boxes. Either platform-locked (iOS only), mobile editions that gut the features, or no public API for customization.

So we wrote our own.


Why Rust × Flutter

This wasn't a preference. It was the only path that met the constraints.

Rust for the audio engine

Rust

Real-time audio has one non-negotiable constraint: the audio callback thread cannot drop a frame. At 48kHz with a 512-sample block size, you have about 10ms to process an entire batch of audio. If a GC pause, thread block, or heap allocation lands inside that 10ms window — the user hears a glitch.

Rust has no GC. Zero-cost abstractions. Memory safety is resolved at compile time — no runtime GC thread stealing your 10ms. The ecosystem already has the low-level wheels: CPAL (cross-platform audio I/O), fundsp (DSP graphs), rustysynth (SoundFont synthesis).

Flutter for UI and cross-platform

Flutter

DAW UIs are notoriously complex — timeline, piano roll, mixer, chord editor — all in one Dart codebase, shipping to iOS, Android, and Desktop simultaneously. Flutter owns its pixels, which suits heavy custom drawing like a timeline — no platform-native widget getting in your way.

Why nothing else worked

Kotlin Multiplatform shares business logic, but the UI is still two codebases — SwiftUI and Compose — doubling your work with daily behavioral inconsistencies between them. React Native's Bridge architecture hits a wall with high-frequency bidirectional data like real-time audio; the serialization overhead eats your performance budget. Pure native — separate iOS and Android teams — is for companies with headcount to burn.

Rust's execution speed + Flutter's development speed was the only path that let a small team ship a cross-platform DAW in reasonable time. More on the Flutter + Rust argument here.


Engine Architecture — Command/Event + Audio Graph

The engine has two separate flows: control and audio.

Control Flow: Command → Engine → Event

Flutter never touches audio engine state directly. Every action is a Command sent downward. Rust processes it and broadcasts Events back:

// Flutter: sends commands, never mutates state directly
engine.sendCommand(PlayRequest());
engine.sendCommand(AddTrackRequest(track));
engine.sendCommand(SetPositionRequest(beat: 16.0));

// Flutter: listens to Rust's event stream
engine.eventStream.listen((event) {
  switch (event) {
    case PlaybackStarted(): ...
    case PlayHeadStateUpdated(:final position): ...
    case TrackAdded(:final trackId): ...
  }
});

Rust runs on its own thread, completely off the UI thread:

fn process_command(engine: &mut Engine, cmd: EngineRequest) {
    match cmd {
        EngineRequest::Play => engine.start_playback(),
        EngineRequest::Stop => engine.stop_playback(),
        EngineRequest::AddTrack(track) => {
            engine.project.add_track(track);
            engine.rebuild_audio_graph();
            engine.broadcast(AudioEvent::TrackAdded { track_id: track.id });
        }
    }
}

Rust is the single source of truth. Flutter holds no project state — everything the UI sees arrives through the event stream. This is the same unidirectional data flow discussed in the Riverpod/Bloc article, just moved from Dart to Rust.

Audio Flow: Node Graph

Audio processing is a DAG (directed acyclic graph). Each track is a processing chain:

PortNode (MIDI input)
TrackInputBridge
ClipsNode (timeline clip playback)
RustySynthNode (SoundFont synthesis) / WaveNode (audio playback)
GainNode (volume, mute, solo)
SummingNode (mix bus)
Output

Ten node types, topologically sorted for execution order. The graph rebuilds only on structural changes (adding/removing tracks, changing routing). Property changes (volume adjustments, clip moves) directly touch the node's atomic flag or mutable reference — no rebuild needed.


The Three Hardest Problems in Real-Time Audio

1. The Audio Callback Must Never Block — try_lock() Everywhere

The audio callback runs on the system audio thread at the highest priority. If this thread blocks for longer than one buffer duration, you get buffer underrun = audible glitch.

So every lock in the hot path is try_lock(). Never lock().

if let Ok(mut processor) = shared.processor.try_lock() {
    processor.process_block_with_snapshot(&mut block, &mut *playhead);
} else {
    block.fill(0.0); // Silence is better than a glitch
}

During an audio graph rebuild, the command thread briefly holds this lock (1-2ms). In that window, the callback gets silence. Users don't notice a few ms of silence. They definitely notice a glitch.

This isn't just the processor — preview player, preview synth, metronome synth, performance stats. Every single thing reachable from the audio callback uses try_lock().

2. Denormal Floats — Silent but CPU-Hungry

Classic audio engineering nightmare. When a signal decays to extremely small values (reverb tail, fade-out edge), floating-point numbers enter the denormal range — values too small for IEEE 754 to represent with normal exponents. The CPU switches to microcode to handle them. One operation goes from 1 cycle to 10-100 cycles. Silence becomes your highest CPU state.

On x86_64, set the MXCSR register:

#[cfg(target_arch = "x86_64")]
unsafe {
    // FTZ: denormal result → flush to zero
    std::arch::x86_64::_MM_SET_FLUSH_ZERO_MODE(
        std::arch::x86_64::_MM_FLUSH_ZERO_ON
    );
    // DAZ: denormal input → treat as zero
    let mut mxcsr = std::arch::x86_64::_mm_getcsr();
    mxcsr |= 1 << 6;
    std::arch::x86_64::_mm_setcsr(mxcsr);
}

ARM (aarch64) has FTZ enabled by default, so iOS and most Android devices don't need this. But on desktop x86_64 — without this, a song at its quietest tail somehow spikes your CPU.

3. Panic Must Not Kill the App — catch_unwind

Rust panic during an audio callback unwinds through the C FFI boundary. On Android, AAudio sees an unwinding panic = SIGABRT = instant crash.

Every audio callback handler is wrapped in catch_unwind:

let result = std::panic::catch_unwind(
    std::panic::AssertUnwindSafe(|| {
        processor.process_block_with_snapshot(&mut block, &mut *playhead);
    })
);
if result.is_err() {
    block.fill(0.0);
}

During development, this is critical — dozens of lazily loaded audio files, SoundFonts, plugins live inside graph nodes. Any one of them failing shouldn't take down the entire app.


Other Cross-Platform Notes

Android AAudio is the wildcard. CPAL's supported_output_configs() panics on some Android devices — not returns an error, panics. We skip device enumeration entirely on Android: use default_output_device(), hardcode 48000Hz sample rate, and let the system decide the buffer size (AAudio doesn't support Fixed). Bluetooth device switching tears down the AAudio stream, so we run a dedicated stream owner thread that monitors device changes and auto-rebuilds — even drop(stream) is wrapped in catch_unwind because CPAL on Android can panic on drop.

iOS is stabler, but microphone permission UX is delicate. Never open an input stream at app launch — iOS immediately shows the mic permission dialog before the user has even touched the record button. Solution: input stream stays closed by default. Open it only when the user taps Record. Release immediately when recording stops.


The engine powers Faso, live at faso.dotune.com. Rust × Flutter is the shortest path for an indie team to ship professional audio software — Rust handles real-time audio, Flutter handles multi-platform UI.

If you're building something similar, or need audio engine / cross-platform DAW development help — reach out.


More in this series: