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
- Architecture
- Real-Time Constraints
- Lock-Free Communication
- Audio Processing Pipeline
- Adding Effects
- Adding Synthesizers
- MIDI System
- Performance Optimization
- Debugging Audio Issues
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(),Stringoperations - 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
- UI initiates action: Send command to audio thread
- Audio thread executes: In
Engine::process(), between buffer fills - Audio thread confirms: Send state update back to UI
- 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
f64seconds - 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:
- Nodes with no dependencies execute first (inputs, oscillators)
- Nodes execute when all inputs are ready
- Cycles are detected and prevented
- 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=1output 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
cpaldevice 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()
Related Documentation
- ARCHITECTURE.md - Overall system architecture
- docs/UI_SYSTEM.md - UI integration with audio system
- docs/BUILDING.md - Build troubleshooting