Lightningbeam/docs/AUDIO_SYSTEM.md

32 KiB

Audio System Architecture

This document describes the architecture of Lightningbeam's audio engine (daw-backend), including real-time constraints, lock-free design patterns, and how to extend the system with new effects and features.

Table of Contents

Overview

The daw-backend crate is a standalone real-time audio engine designed for:

  • Multi-track audio playback and recording
  • Real-time audio effects processing
  • MIDI input/output and sequencing
  • Modular audio routing (node graph system)
  • Audio export (WAV, MP3, AAC)

Key Features

  • Lock-free design for real-time safety
  • Cross-platform audio I/O via cpal
  • Audio decoding via symphonia (MP3, FLAC, WAV, Ogg, AAC)
  • Node-based audio graph processing
  • Comprehensive effects library
  • Multiple synthesizer types
  • Zero-allocation audio thread

Architecture

┌─────────────────────────────────────────────────────────────┐
│                         UI Thread                           │
│  (lightningbeam-editor or other application)                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  AudioSystem::new() ─────> Creates audio stream             │
│         │                                                   │
│         ├─> command_sender (rtrb::Producer)                 │
│         └─> state_receiver (rtrb::Consumer)                 │
│                                                             │
│  Commands sent:                                             │
│    - Play / Stop / Seek                                     │
│    - Add / Remove tracks                                    │
│    - Load audio files                                       │
│    - Add / Remove effects                                   │
│    - Update parameters                                      │
│                                                             │
└──────────────────────┬──────────────────────────────────────┘
                       │
                       │ Lock-free queues (rtrb)
                       │
┌──────────────────────▼──────────────────────────────────────┐
│                    Audio Thread (Real-Time)                 │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Engine::process(output_buffer)                             │
│    │                                                        │
│    ├─> Receive commands from queue                          │
│    ├─> Update playhead position                             │
│    ├─> For each track:                                      │
│    │     ├─> Read audio samples at playhead                 │
│    │     ├─> Apply effects chain                            │
│    │     └─> Mix to output                                  │
│    ├─> Apply master effects                                 │
│    └─> Write samples to output_buffer                       │
│                                                             │
│  Send state updates back to UI thread                       │
│    - Playhead position                                      │
│    - Meter levels                                           │
│    - Overrun warnings                                       │
│                                                             │
└──────────────────────┬──────────────────────────────────────┘
                       │
                       ▼
                 ┌───────────┐
                 │   cpal    │
                 │  (Audio   │
                 │   I/O)    │
                 └───────────┘
                       │
                       ▼
                ┌──────────────┐
                │ Audio Output │
                │  (Speakers)  │
                └──────────────┘

Core Components

AudioSystem (src/lib.rs)

  • Entry point for the audio engine
  • Creates the audio stream
  • Sets up lock-free communication channels
  • Manages audio device configuration

Engine (src/audio/engine.rs)

  • The main audio callback
  • Runs on the real-time audio thread
  • Processes commands, mixes tracks, applies effects
  • Must complete in ~5ms (at 44.1kHz, 256 frame buffer)

Project (src/audio/project.rs)

  • Top-level audio state
  • Contains tracks, tempo, time signature
  • Manages global settings

Track (src/audio/track.rs)

  • Individual audio track
  • Contains audio clips and effects chain
  • Handles track-specific state (volume, pan, mute, solo)

Real-Time Constraints

The Golden Rule

The audio thread must NEVER block.

Audio callbacks run with strict timing deadlines:

  • Buffer size: 256 frames (default) = ~5.8ms at 44.1kHz
  • ALSA on Linux: May provide smaller buffers (64-75 frames = ~1.5ms)
  • Deadline: Audio callback must complete before next buffer is needed

If the audio callback takes too long:

  • Audio dropout: Audible glitch/pop in output
  • Buffer underrun: Missing samples
  • System instability: Priority inversion, thread starvation

Forbidden Operations in Audio Thread

Never do these in the audio callback:

  • Locking: Mutex, RwLock, or any blocking synchronization
  • Allocation: Vec::push(), Box::new(), String operations
  • I/O: File operations, network, print statements
  • System calls: Most OS operations
  • Unbounded loops: Must have guaranteed completion time

Safe operations:

  • Reading/writing lock-free queues (rtrb)
  • Fixed-size array operations
  • Arithmetic and DSP calculations
  • Pre-allocated buffer operations

Optimized Debug Builds

To meet real-time deadlines, audio code is compiled with optimizations even in debug builds:

# In lightningbeam-ui/Cargo.toml
[profile.dev.package.daw-backend]
opt-level = 2

[profile.dev.package.symphonia]
opt-level = 2
# ... other audio libraries

This allows fast iteration while maintaining audio performance.

Lock-Free Communication

Command Queue (UI → Audio)

The UI thread sends commands to the audio thread via a lock-free ringbuffer:

// UI Thread
let command = AudioCommand::Play;
command_sender.push(command).ok();

// Audio Thread (in Engine::process)
while let Ok(command) = command_receiver.pop() {
    match command {
        AudioCommand::Play => self.playing = true,
        AudioCommand::Stop => self.playing = false,
        AudioCommand::Seek(time) => self.playhead = time,
        // ... handle other commands
    }
}

State Updates (Audio → UI)

The audio thread sends state updates back to the UI:

// Audio Thread
let state = AudioState {
    playhead: self.playhead,
    is_playing: self.playing,
    meter_levels: self.compute_meters(),
};
state_sender.push(state).ok();

// UI Thread
if let Ok(state) = state_receiver.pop() {
    // Update UI with new state
}

Design Pattern: Command-Response

  1. UI initiates action: Send command to audio thread
  2. Audio thread executes: In Engine::process(), between buffer fills
  3. Audio thread confirms: Send state update back to UI
  4. UI updates: Reflect new state in user interface

This pattern ensures:

  • No blocking on either side
  • UI remains responsive
  • Audio thread never waits

Audio Processing Pipeline

Per-Buffer Processing

Every audio buffer (typically 256 frames), the Engine::process() callback:

pub fn process(&mut self, output: &mut [f32]) -> Result<(), AudioError> {
    // 1. Process commands from UI thread
    self.process_commands();

    // 2. Update playhead
    if self.playing {
        self.playhead += buffer_duration;
    }

    // 3. Clear output buffer
    output.fill(0.0);

    // 4. Process each track
    for track in &mut self.tracks {
        if track.muted {
            continue;
        }

        // Read audio samples at playhead position
        let samples = track.read_samples(self.playhead, output.len());

        // Apply track effects chain
        let mut processed = samples;
        for effect in &mut track.effects {
            processed = effect.process(processed);
        }

        // Mix to output with volume/pan
        mix_to_output(output, &processed, track.volume, track.pan);
    }

    // 5. Apply master effects
    for effect in &mut self.master_effects {
        effect.process_in_place(output);
    }

    // 6. Send state updates to UI
    self.send_state_update();

    Ok(())
}

Sample Rate and Buffer Size

  • Sample rate: 44.1kHz (default) or 48kHz
  • Buffer size: 256 frames (configurable)
  • Channels: Stereo (2 channels)

Buffer is interleaved: [L, R, L, R, L, R, ...]

Time Representation

  • Playhead position: Stored as f64 seconds
  • Sample index: (playhead * sample_rate) as usize
  • Frame index: sample_index / channels

Node Graph System

Overview

Tracks use a node graph architecture powered by dasp_graph for flexible audio routing. Unlike simple serial effect chains, the node graph allows:

  • Parallel processing: Multiple effects processing the same input
  • Complex routing: Effects feeding into each other in arbitrary configurations
  • Modular synthesis: Build synthesizers from oscillators, filters, and modulators
  • Send/return chains: Shared effects (reverb, delay) fed by multiple tracks
  • Sidechain processing: One signal controlling another (compression, vocoding)

Node Graph Architecture

┌─────────────────────────────────────────────────────────┐
│                      Track Node Graph                    │
├─────────────────────────────────────────────────────────┤
│                                                          │
│   ┌─────────┐                                           │
│   │ Input   │ (Audio clip or synthesizer)               │
│   └────┬────┘                                           │
│        │                                                 │
│        ├──────┬──────────────┬─────────────┐           │
│        │      │              │             │           │
│        ▼      ▼              ▼             ▼           │
│   ┌────────┐ ┌────────┐ ┌────────┐  ┌─────────┐      │
│   │Filter  │ │Distort │ │  EQ    │  │ Reverb  │      │
│   │(Node 1)│ │(Node 2)│ │(Node 3)│  │(Node 4) │      │
│   └───┬────┘ └───┬────┘ └───┬────┘  └────┬────┘      │
│       │          │          │             │           │
│       └────┬─────┴──────┬───┘             │           │
│            │            │                 │           │
│            ▼            ▼                 │           │
│       ┌─────────┐  ┌─────────┐           │           │
│       │ Mixer   │  │Compress │           │           │
│       │(Node 5) │  │(Node 6) │◄──────────┘           │
│       └────┬────┘  └────┬────┘   (sidechain)         │
│            │            │                             │
│            └─────┬──────┘                             │
│                  │                                     │
│                  ▼                                     │
│            ┌──────────┐                               │
│            │  Output  │                               │
│            └──────────┘                               │
│                                                        │
└────────────────────────────────────────────────────────┘

Node Types

Input Nodes

  • Audio Clip Reader: Reads samples from audio file
  • Oscillator: Generates waveforms (sine, saw, square, triangle)
  • Noise Generator: White/pink noise
  • External Input: Microphone or line-in

Processing Nodes

  • Effects: Any audio effect (see Adding Effects)
  • Filters: Low-pass, high-pass, band-pass, notch
  • Mixers: Combine multiple inputs with gain control
  • Splitters: Duplicate signal to multiple outputs

Output Nodes

  • Track Output: Sends to mixer or master bus
  • Send Output: Feeds auxiliary effects

Building a Node Graph

use dasp_graph::{Node, NodeData, Input, BoxedNode};
use petgraph::graph::NodeIndex;

pub struct TrackGraph {
    graph: dasp_graph::Graph,
    input_node: NodeIndex,
    output_node: NodeIndex,
}

impl TrackGraph {
    pub fn new() -> Self {
        let mut graph = dasp_graph::Graph::new();

        // Create input and output nodes
        let input_node = graph.add_node(NodeData::new1(
            Input::default(),
            PassThrough, // Simple input node
        ));

        let output_node = graph.add_node(NodeData::new1(
            Input::default(),
            PassThrough, // Simple output node
        ));

        Self {
            graph,
            input_node,
            output_node,
        }
    }

    pub fn add_effect(&mut self, effect: BoxedNode) -> NodeIndex {
        // Add effect node between input and output
        let effect_node = self.graph.add_node(NodeData::new1(
            Input::default(),
            effect,
        ));

        // Connect: input -> effect -> output
        self.graph.add_edge(self.input_node, effect_node, ());
        self.graph.add_edge(effect_node, self.output_node, ());

        effect_node
    }

    pub fn connect(&mut self, from: NodeIndex, to: NodeIndex) {
        self.graph.add_edge(from, to, ());
    }

    pub fn process(&mut self, input: &[f32], output: &mut [f32]) {
        // Set input samples
        self.graph.set_input(self.input_node, input);

        // Process entire graph
        self.graph.process();

        // Read output samples
        self.graph.get_output(self.output_node, output);
    }
}

Example: Serial Effect Chain

Simple effects chain (the most common case):

// Input -> Distortion -> EQ -> Reverb -> Output

let mut graph = TrackGraph::new();

let distortion = graph.add_effect(Box::new(Distortion::new(0.5)));
let eq = graph.add_effect(Box::new(EQ::new()));
let reverb = graph.add_effect(Box::new(Reverb::new()));

// Connect in series
graph.connect(graph.input_node, distortion);
graph.connect(distortion, eq);
graph.connect(eq, reverb);
graph.connect(reverb, graph.output_node);

Example: Parallel Processing

Split signal into parallel paths:

// Input -> Split -> [Distortion + Clean] -> Mix -> Output

let mut graph = TrackGraph::new();

// Create parallel paths
let distortion = graph.add_effect(Box::new(Distortion::new(0.7)));
let clean = graph.add_effect(Box::new(Gain::new(1.0)));
let mixer = graph.add_effect(Box::new(Mixer::new(2))); // 2 inputs

// Connect parallel paths
graph.connect(graph.input_node, distortion);
graph.connect(graph.input_node, clean);
graph.connect(distortion, mixer);
graph.connect(clean, mixer);
graph.connect(mixer, graph.output_node);

Example: Modular Synthesizer

Build a synthesizer from basic components:

//     ┌─ LFO ────┐ (modulation)
//     │          ▼
// Oscillator -> Filter -> Envelope -> Output

let mut graph = TrackGraph::new();

// Sound source
let oscillator = graph.add_effect(Box::new(Oscillator::new(440.0)));

// Modulation source
let lfo = graph.add_effect(Box::new(LFO::new(5.0))); // 5 Hz

// Filter with LFO modulation
let filter = graph.add_effect(Box::new(Filter::new_modulated()));

// Envelope
let envelope = graph.add_effect(Box::new(ADSREnvelope::new()));

// Connect sound path
graph.connect(oscillator, filter);
graph.connect(filter, envelope);
graph.connect(envelope, graph.output_node);

// Connect modulation path
graph.connect(lfo, filter); // LFO modulates filter cutoff

Example: Sidechain Compression

One signal controls another:

// Input (bass) ──────────────────┐
//                                ▼
// Kick drum ────> Compressor (sidechain) -> Output

let mut graph = TrackGraph::new();

// Main signal input (bass)
let bass_input = graph.add_effect(Box::new(PassThrough));

// Sidechain signal input (kick drum)
let kick_input = graph.add_effect(Box::new(PassThrough));

// Compressor with sidechain
let compressor = graph.add_effect(Box::new(SidechainCompressor::new()));

// Connect main signal
graph.connect(bass_input, compressor);

// Connect sidechain signal (port 1 = main, port 2 = sidechain)
graph.connect_to_port(kick_input, compressor, 1);

graph.connect(compressor, graph.output_node);

Node Interface

All nodes implement the dasp_graph::Node trait:

pub trait Node {
    /// Process audio for this node
    fn process(&mut self, inputs: &[Input], output: &mut [f32]);

    /// Number of input ports
    fn num_inputs(&self) -> usize;

    /// Number of output ports
    fn num_outputs(&self) -> usize;

    /// Reset internal state
    fn reset(&mut self);
}

Multi-Channel Processing

Nodes can have multiple input/output channels:

pub struct StereoEffect {
    left_processor: Processor,
    right_processor: Processor,
}

impl Node for StereoEffect {
    fn process(&mut self, inputs: &[Input], output: &mut [f32]) {
        // Split stereo input
        let (left_in, right_in) = inputs[0].as_stereo();

        // Process each channel
        let left_out = self.left_processor.process(left_in);
        let right_out = self.right_processor.process(right_in);

        // Interleave output
        for i in 0..left_out.len() {
            output[i * 2] = left_out[i];
            output[i * 2 + 1] = right_out[i];
        }
    }

    fn num_inputs(&self) -> usize { 1 } // One stereo input
    fn num_outputs(&self) -> usize { 1 } // One stereo output

    fn reset(&mut self) {
        self.left_processor.reset();
        self.right_processor.reset();
    }
}

Parameter Modulation

Nodes can expose parameters for automation or modulation:

pub struct ModulatableFilter {
    filter: Filter,
    cutoff: f32,
    resonance: f32,
}

impl Node for ModulatableFilter {
    fn process(&mut self, inputs: &[Input], output: &mut [f32]) {
        let audio_in = &inputs[0]; // Port 0: audio input

        // Port 1 (optional): cutoff modulation
        if inputs.len() > 1 {
            let mod_signal = &inputs[1];
            // Modulate cutoff: base + modulation
            self.filter.set_cutoff(self.cutoff + mod_signal[0] * 1000.0);
        }

        // Process audio
        self.filter.process(audio_in, output);
    }

    fn num_inputs(&self) -> usize { 2 } // Audio + modulation
    fn num_outputs(&self) -> usize { 1 }

    fn reset(&mut self) {
        self.filter.reset();
    }
}

Graph Execution Order

dasp_graph automatically determines execution order using topological sort:

  1. Nodes with no dependencies execute first (inputs, oscillators)
  2. Nodes execute when all inputs are ready
  3. Cycles are detected and prevented
  4. Output nodes execute last

This ensures:

  • No node processes before its inputs are ready
  • Efficient CPU cache usage
  • Deterministic execution

Performance Considerations

Graph Overhead

Node graphs have small overhead:

  • Topological sort: Done once when graph changes, not per-buffer
  • Buffer copying: Minimized by reusing buffers
  • Indirection: Virtual function calls (unavoidable with trait objects)

For simple serial chains, the overhead is negligible (<1% CPU).

When to Use Node Graphs vs Simple Chains

Use node graphs when:

  • Complex routing (parallel, feedback, modulation)
  • Building synthesizers from components
  • User-configurable effect routing
  • Sidechain processing

Use simple chains when:

  • Just a few effects in series
  • Performance is critical
  • Graph structure never changes

Note: In Lightningbeam, audio layers always use node graphs to provide maximum flexibility for users. This allows any track to have complex routing, modular synthesis, or effect configurations without requiring different track types.

// Simple chain (no graph overhead)
pub struct SimpleChain {
    effects: Vec<Box<dyn AudioEffect>>,
}

impl SimpleChain {
    fn process(&mut self, buffer: &mut [f32]) {
        for effect in &mut self.effects {
            effect.process_in_place(buffer);
        }
    }
}

Debugging Node Graphs

Enable graph visualization:

// Print graph structure
println!("{:?}", graph);

// Export to DOT format for visualization
let dot = graph.to_dot();
std::fs::write("graph.dot", dot)?;
// Then: dot -Tpng graph.dot -o graph.png

Trace signal flow:

// Add probe nodes to inspect signals
let probe = graph.add_effect(Box::new(SignalProbe::new("After Filter")));
graph.connect(filter, probe);
graph.connect(probe, output);

// Probe prints min/max/RMS of signal

Adding Effects

Effect Trait

All effects implement the AudioEffect trait:

pub trait AudioEffect: Send {
    fn process(&mut self, input: &[f32], output: &mut [f32]);
    fn process_in_place(&mut self, buffer: &mut [f32]);
    fn reset(&mut self);
}

Example: Simple Gain Effect

pub struct Gain {
    gain: f32,
}

impl Gain {
    pub fn new(gain: f32) -> Self {
        Self { gain }
    }
}

impl AudioEffect for Gain {
    fn process(&mut self, input: &[f32], output: &mut [f32]) {
        for (i, &sample) in input.iter().enumerate() {
            output[i] = sample * self.gain;
        }
    }

    fn process_in_place(&mut self, buffer: &mut [f32]) {
        for sample in buffer.iter_mut() {
            *sample *= self.gain;
        }
    }

    fn reset(&mut self) {
        // No state to reset for gain
    }
}

Example: Delay Effect (with state)

pub struct Delay {
    buffer: Vec<f32>,
    write_pos: usize,
    delay_samples: usize,
    feedback: f32,
    mix: f32,
}

impl Delay {
    pub fn new(sample_rate: f32, delay_time: f32, feedback: f32, mix: f32) -> Self {
        let delay_samples = (delay_time * sample_rate) as usize;
        let buffer_size = delay_samples.next_power_of_two();

        Self {
            buffer: vec![0.0; buffer_size],
            write_pos: 0,
            delay_samples,
            feedback,
            mix,
        }
    }
}

impl AudioEffect for Delay {
    fn process_in_place(&mut self, buffer: &mut [f32]) {
        for sample in buffer.iter_mut() {
            // Read delayed sample
            let read_pos = (self.write_pos + self.buffer.len() - self.delay_samples)
                           % self.buffer.len();
            let delayed = self.buffer[read_pos];

            // Write new sample with feedback
            self.buffer[self.write_pos] = *sample + delayed * self.feedback;
            self.write_pos = (self.write_pos + 1) % self.buffer.len();

            // Mix dry and wet signals
            *sample = *sample * (1.0 - self.mix) + delayed * self.mix;
        }
    }

    fn reset(&mut self) {
        self.buffer.fill(0.0);
        self.write_pos = 0;
    }
}

Adding Effects to Tracks

// UI Thread
let command = AudioCommand::AddEffect {
    track_id: track_id,
    effect: Box::new(Delay::new(44100.0, 0.5, 0.3, 0.5)),
};
command_sender.push(command).ok();

Built-In Effects

Located in daw-backend/src/effects/:

  • reverb.rs: Reverb
  • delay.rs: Delay
  • eq.rs: Equalizer
  • compressor.rs: Dynamic range compressor
  • distortion.rs: Distortion/overdrive
  • chorus.rs: Chorus
  • flanger.rs: Flanger
  • phaser.rs: Phaser
  • limiter.rs: Brick-wall limiter

Adding Synthesizers

Synthesizer Trait

pub trait Synthesizer: Send {
    fn process(&mut self, output: &mut [f32], sample_rate: f32);
    fn note_on(&mut self, note: u8, velocity: u8);
    fn note_off(&mut self, note: u8);
    fn reset(&mut self);
}

Example: Simple Oscillator

pub struct Oscillator {
    phase: f32,
    frequency: f32,
    amplitude: f32,
    sample_rate: f32,
}

impl Oscillator {
    pub fn new(sample_rate: f32) -> Self {
        Self {
            phase: 0.0,
            frequency: 440.0,
            amplitude: 0.0,
            sample_rate,
        }
    }
}

impl Synthesizer for Oscillator {
    fn process(&mut self, output: &mut [f32], _sample_rate: f32) {
        for sample in output.iter_mut() {
            // Generate sine wave
            *sample = (self.phase * 2.0 * std::f32::consts::PI).sin() * self.amplitude;

            // Advance phase
            self.phase += self.frequency / self.sample_rate;
            if self.phase >= 1.0 {
                self.phase -= 1.0;
            }
        }
    }

    fn note_on(&mut self, note: u8, velocity: u8) {
        // Convert MIDI note to frequency
        self.frequency = 440.0 * 2.0_f32.powf((note as f32 - 69.0) / 12.0);
        self.amplitude = velocity as f32 / 127.0;
    }

    fn note_off(&mut self, _note: u8) {
        self.amplitude = 0.0;
    }

    fn reset(&mut self) {
        self.phase = 0.0;
        self.amplitude = 0.0;
    }
}

Built-In Synthesizers

Located in daw-backend/src/synth/:

  • oscillator.rs: Basic waveform generator (sine, saw, square, triangle)
  • fm_synth.rs: FM synthesis
  • wavetable.rs: Wavetable synthesis
  • sampler.rs: Sample-based synthesis

MIDI System

MIDI Input

// Setup MIDI input (UI thread)
let midi_input = midir::MidiInput::new("Lightningbeam")?;
let port = midi_input.ports()[0];

midi_input.connect(&port, "input", move |_timestamp, message, _| {
    // Parse MIDI message
    match message[0] & 0xF0 {
        0x90 => {
            // Note On
            let note = message[1];
            let velocity = message[2];
            command_sender.push(AudioCommand::NoteOn { note, velocity }).ok();
        }
        0x80 => {
            // Note Off
            let note = message[1];
            command_sender.push(AudioCommand::NoteOff { note }).ok();
        }
        _ => {}
    }
}, ())?;

MIDI File Parsing

use midly::{Smf, TrackEventKind};

let smf = Smf::parse(&midi_data)?;
for track in smf.tracks {
    for event in track {
        match event.kind {
            TrackEventKind::Midi { channel, message } => {
                // Process MIDI message
            }
            _ => {}
        }
    }
}

Performance Optimization

Pre-Allocation

Allocate all buffers before audio thread starts:

// Good: Pre-allocated
pub struct Track {
    buffer: Vec<f32>,  // Allocated once in constructor
    // ...
}

// Bad: Allocates in audio thread
fn process(&mut self) {
    let mut temp = Vec::new();  // ❌ Allocates!
    // ...
}

Memory-Mapped Audio Files

Large audio files use memory-mapped I/O for zero-copy access:

use memmap2::Mmap;

let file = File::open(path)?;
let mmap = unsafe { Mmap::map(&file)? };
// Audio samples can be read directly from mmap

SIMD Optimization

For portable SIMD operations, use the fearless_simd crate:

use fearless_simd::*;

fn process_simd(samples: &mut [f32], gain: f32) {
    // Automatically uses best available SIMD instructions
    // (SSE, AVX, NEON, etc.) without unsafe code
    for chunk in samples.chunks_exact_mut(f32x8::LEN) {
        let simd_samples = f32x8::from_slice(chunk);
        let simd_gain = f32x8::splat(gain);
        let result = simd_samples * simd_gain;
        result.write_to_slice(chunk);
    }

    // Handle remainder
    let remainder = samples.chunks_exact_mut(f32x8::LEN).into_remainder();
    for sample in remainder {
        *sample *= gain;
    }
}

This approach is:

  • Portable: Works across x86, ARM, and other architectures
  • Safe: No unsafe code required
  • Automatic: Uses best available SIMD instructions for the target
  • Fallback: Gracefully degrades on platforms without SIMD

Avoid Branching in Inner Loops

// Bad: Branch in inner loop
for sample in samples.iter_mut() {
    if self.gain > 0.5 {
        *sample *= 2.0;
    }
}

// Good: Branch outside loop
let multiplier = if self.gain > 0.5 { 2.0 } else { 1.0 };
for sample in samples.iter_mut() {
    *sample *= multiplier;
}

Debugging Audio Issues

Enable Debug Logging

DAW_AUDIO_DEBUG=1 cargo run

Output includes:

[AUDIO] Buffer size: 256 frames (5.8ms at 44100 Hz)
[AUDIO] Processing time: avg=0.8ms, worst=2.1ms
[AUDIO] Playhead: 1.234s
[AUDIO] WARNING: Audio overrun detected!

Common Issues

Audio Dropouts

Symptoms: Clicks, pops, glitches in audio output

Causes:

  • Audio callback taking too long
  • Blocking operation in audio thread
  • Insufficient CPU resources

Solutions:

  • Increase buffer size (reduces CPU pressure, increases latency)
  • Optimize audio processing code
  • Remove debug prints from audio thread
  • Check DAW_AUDIO_DEBUG=1 output for timing info

Crackling/Distortion

Symptoms: Harsh, noisy audio

Causes:

  • Samples exceeding [-1.0, 1.0] range (clipping)
  • Incorrect sample rate conversion
  • Denormal numbers in filters

Solutions:

  • Add limiter to master output
  • Use hard clipping: sample.clamp(-1.0, 1.0)
  • Enable flush-to-zero for denormals

No Audio Output

Symptoms: Silence, but no errors

Causes:

  • Audio device not found
  • Wrong device selected
  • All tracks muted
  • Volume set to zero

Solutions:

  • Check cpal device enumeration
  • Verify track volumes and mute states
  • Check master volume
  • Test with simple sine wave

Profiling Audio Performance

# Use perf on Linux
perf record --call-graph dwarf cargo run --release
perf report

# Look for hot spots in Engine::process()