Lightningbeam/daw-backend/daw_architecture_doc.md

1807 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# DAW Backend Architecture & Implementation Roadmap
**Version:** 1.0
**Date:** October 2025
**Language:** Rust
**Audio I/O:** cpal
---
## Table of Contents
1. [Architecture Overview](#architecture-overview)
2. [Core Components](#core-components)
3. [Metatracks Architecture](#metatracks-architecture)
4. [Implementation Roadmap](#implementation-roadmap)
5. [Technical Specifications](#technical-specifications)
6. [Testing Strategy](#testing-strategy)
---
## Architecture Overview
### High-Level Design
The DAW follows a **multi-threaded, message-passing architecture** that separates real-time audio processing from UI and control logic:
```
┌─────────────┐ Commands ┌─────────────┐ Commands ┌─────────────┐
│ UI Thread │ ←──────────────→ │Control Thread│ ←──────────────→ │ Audio Thread│
└─────────────┘ Events └─────────────┘ Lock-free └─────────────┘
│ Queues │
↓ │
┌─────────────┐ │
│Project State│←────────────────────────┘
│(Triple-buf) │ Atomic reads
└─────────────┘
```
### Design Principles
1. **Real-time Safety**: Audio thread is lock-free and allocation-free
2. **Hierarchical Composition**: Tracks can contain other tracks (metatracks)
3. **Message-Based Communication**: All cross-thread communication via lock-free queues
4. **Incremental Complexity**: Architecture supports simple flat tracks initially, scales to nested metatracks
5. **Separation of Concerns**: Audio processing, state management, and UI are decoupled
---
## Core Components
### 1. Audio Engine (Real-time Thread)
**Responsibilities:**
- Process audio graph in response to cpal callbacks
- Execute commands from control thread
- Maintain playback position and transport state
- Send events back to control thread
**Constraints:**
- No memory allocations
- No blocking operations (mutex, I/O)
- No unbounded loops
- Pre-allocated buffers only
**Key Structures:**
```rust
struct AudioEngine {
tracks: Vec<TrackNode>,
playhead: u64, // Sample position
playing: bool,
sample_rate: u32,
// Communication
command_rx: rtrb::Consumer<Command>,
event_tx: rtrb::Producer<AudioEvent>,
// Pre-allocated resources
mix_buffer: Vec<f32>,
buffer_pool: BufferPool,
}
enum Command {
Play,
Stop,
Seek(f64),
SetTempo(f32),
UpdateTrackVolume(TrackId, f32),
UpdateTrackMute(TrackId, bool),
AddEffect(TrackId, EffectType),
// ... more commands
}
enum AudioEvent {
PlaybackPosition(f64),
PeakLevel(TrackId, f32),
BufferUnderrun,
// ... more events
}
```
### 2. Track Hierarchy
**Track Node Types:**
```rust
enum TrackNode {
Audio(AudioTrack),
Midi(MidiTrack),
Metatrack(Metatrack),
Bus(BusTrack),
}
struct AudioTrack {
id: TrackId,
name: String,
clips: Vec<Clip>,
effects: Vec<Box<dyn Effect>>,
volume: f32,
pan: f32,
muted: bool,
solo: bool,
parent: Option<TrackId>,
}
struct MidiTrack {
id: TrackId,
name: String,
clips: Vec<MidiClip>,
instrument: Box<dyn Effect>, // Virtual instrument
effects: Vec<Box<dyn Effect>>,
volume: f32,
pan: f32,
muted: bool,
solo: bool,
parent: Option<TrackId>,
}
struct Metatrack {
id: TrackId,
name: String,
children: Vec<TrackId>,
effects: Vec<Box<dyn Effect>>,
// Metatrack-specific features
time_stretch: f32, // Speed multiplier (0.5 = half speed)
pitch_shift: f32, // Semitones
offset: f64, // Time offset in seconds
volume: f32,
pan: f32,
muted: bool,
solo: bool,
parent: Option<TrackId>,
// UI hints
collapsed: bool,
color: Color,
}
struct BusTrack {
id: TrackId,
name: String,
inputs: Vec<TrackId>, // Which tracks send to this bus
effects: Vec<Box<dyn Effect>>,
volume: f32,
pan: f32,
}
```
### 3. Clips and Regions
```rust
struct Clip {
id: ClipId,
content: ClipContent,
start_time: f64, // Position in parent track (seconds)
duration: f64, // Clip duration (seconds)
offset: f64, // Offset into content (seconds)
// Clip-level processing
gain: f32,
fade_in: f64,
fade_out: f64,
reversed: bool,
}
enum ClipContent {
AudioFile {
pool_index: usize, // Index into AudioPool
},
MidiData {
events: Vec<MidiEvent>,
},
MetatrackReference {
track_id: TrackId,
},
}
struct MidiEvent {
timestamp: u64, // Sample offset within clip
status: u8,
data1: u8,
data2: u8,
}
```
### 4. Audio Pool
Shared audio file storage:
```rust
struct AudioPool {
files: Vec<AudioFile>,
cache: LruCache<FileId, Vec<f32>>,
}
struct AudioFile {
id: FileId,
path: PathBuf,
data: Vec<f32>, // Interleaved samples
channels: u32,
sample_rate: u32,
frames: u64,
}
```
### 5. Effect System
```rust
trait Effect: Send {
fn process(&mut self, buffer: &mut [f32], channels: usize, sample_rate: u32);
fn set_parameter(&mut self, id: u32, value: f32);
fn get_parameter(&self, id: u32) -> f32;
fn reset(&mut self);
}
// Example implementations
struct GainEffect {
gain_db: f32,
}
struct SimpleEQ {
low_gain: f32,
mid_gain: f32,
high_gain: f32,
filters: [BiquadFilter; 3],
}
struct SimpleSynth {
oscillators: Vec<Oscillator>,
adsr: AdsrEnvelope,
}
```
### 6. Render Context
Carries time and tempo information through the track hierarchy:
```rust
struct RenderContext {
global_position: u64, // Absolute sample position
local_position: u64, // Position within current scope
sample_rate: u32,
tempo: f32,
time_signature: (u32, u32),
time_stretch: f32, // Accumulated stretch factor
}
impl Metatrack {
fn transform_context(&self, ctx: RenderContext) -> RenderContext {
let offset_samples = (self.offset * ctx.sample_rate as f64) as u64;
let local_pos = ((ctx.local_position.saturating_sub(offset_samples)) as f64
/ self.time_stretch as f64) as u64;
RenderContext {
global_position: ctx.global_position,
local_position: local_pos,
sample_rate: ctx.sample_rate,
tempo: ctx.tempo * self.time_stretch,
time_signature: ctx.time_signature,
time_stretch: ctx.time_stretch * self.time_stretch,
}
}
}
```
### 7. Project State
```rust
struct Project {
tracks: HashMap<TrackId, TrackNode>,
root_tracks: Vec<TrackId>,
audio_pool: AudioPool,
// Global settings
sample_rate: u32,
tempo: f32,
time_signature: (u32, u32),
// Metadata
name: String,
created: SystemTime,
modified: SystemTime,
}
impl Project {
fn get_processing_order(&self) -> Vec<TrackId> {
// Depth-first traversal for correct rendering order
let mut order = Vec::new();
for root_id in &self.root_tracks {
self.collect_depth_first(*root_id, &mut order);
}
order
}
fn collect_depth_first(&self, id: TrackId, order: &mut Vec<TrackId>) {
if let Some(TrackNode::Metatrack(meta)) = self.tracks.get(&id) {
for child_id in &meta.children {
self.collect_depth_first(*child_id, order);
}
}
order.push(id);
}
}
```
### 8. Buffer Management
```rust
struct BufferPool {
buffers: Vec<Vec<f32>>,
available: Vec<usize>,
buffer_size: usize,
}
impl BufferPool {
fn acquire(&mut self) -> Vec<f32> {
if let Some(idx) = self.available.pop() {
let mut buf = std::mem::take(&mut self.buffers[idx]);
buf.fill(0.0);
buf
} else {
vec![0.0; self.buffer_size]
}
}
fn release(&mut self, buffer: Vec<f32>) {
let idx = self.buffers.len();
self.buffers.push(buffer);
self.available.push(idx);
}
}
```
---
## Metatracks Architecture
### Processing Model
Metatracks use a **pre-mix** model:
1. Mix all children into a temporary buffer
2. Apply metatrack's effects to the mixed buffer
3. Mix result into parent's output
```rust
fn process_metatrack(
meta: &Metatrack,
project: &Project,
output: &mut [f32],
context: RenderContext,
buffer_pool: &mut BufferPool,
) {
// Transform context for children
let child_context = meta.transform_context(context);
// Acquire scratch buffer
let mut submix = buffer_pool.acquire();
submix.resize(output.len(), 0.0);
// Process all children into submix
for child_id in &meta.children {
if let Some(child) = project.tracks.get(child_id) {
process_track_node(
child,
project,
&mut submix,
child_context,
buffer_pool
);
}
}
// Apply metatrack's effects
for effect in &mut meta.effects {
effect.process(&mut submix, 2, context.sample_rate);
}
// Mix into output with volume
for (out, sub) in output.iter_mut().zip(submix.iter()) {
*out += sub * meta.volume;
}
// Return buffer to pool
buffer_pool.release(submix);
}
```
### Time Transformation
Metatracks can manipulate time for all children:
- **Time Stretch**: Speed up or slow down playback
- **Offset**: Shift content in time
- **Pitch Shift**: Transpose content (future feature, requires pitch-preserving time stretch)
### Metatrack Operations
```rust
enum MetatrackOperation {
// Creation
CreateFromSelection(Vec<TrackId>),
CreateEmpty,
// Hierarchy manipulation
AddToMetatrack(TrackId, Vec<TrackId>),
RemoveFromMetatrack(TrackId, Vec<TrackId>),
MoveToMetatrack { track: TrackId, new_parent: TrackId },
Ungroup(TrackId),
Flatten(TrackId),
// Transformation
SetTimeStretch(TrackId, f32),
SetOffset(TrackId, f64),
// Rendering
BounceToAudio(TrackId),
Freeze(TrackId),
Unfreeze(TrackId),
}
```
### Nesting Limits
- **Recommended maximum depth**: 10 levels
- **Reason**: Performance and UI complexity
- **Implementation**: Check depth during metatrack creation
---
## Implementation Roadmap
### Phase 1: Single Audio File Playback (Week 1-2)
**Goal**: Play one audio file through speakers
**Deliverables:**
- Basic cpal integration
- Load audio file with symphonia
- Simple playback loop
- Press spacebar to play/pause
**Core Implementation:**
```rust
struct SimpleEngine {
audio_data: Vec<f32>,
playhead: usize,
sample_rate: u32,
playing: Arc<AtomicBool>,
}
// Main audio callback
fn audio_callback(data: &mut [f32], engine: &mut SimpleEngine) {
if engine.playing.load(Ordering::Relaxed) {
let end = (engine.playhead + data.len()).min(engine.audio_data.len());
let available = end - engine.playhead;
data[..available].copy_from_slice(
&engine.audio_data[engine.playhead..end]
);
engine.playhead = end;
} else {
data.fill(0.0);
}
}
```
**Dependencies:**
- `cpal = "0.15"`
- `symphonia = "0.5"`
**Success Criteria:**
- Audio plays without clicks or pops
- Can start/stop playback
- No audio thread panics
---
### Phase 2: Transport Control + UI Communication (Week 2-3)
**Goal**: Start/stop/seek from a basic UI
**Deliverables:**
- Lock-free command queue
- Atomic playhead position
- Basic UI (terminal or simple window)
- Play/pause/seek controls
**Core Implementation:**
```rust
enum Command {
Play,
Stop,
Seek(f64),
}
struct Engine {
audio_data: Vec<f32>,
playhead: Arc<AtomicU64>,
command_rx: rtrb::Consumer<Command>,
playing: bool,
sample_rate: u32,
}
fn audio_callback(data: &mut [f32], engine: &mut Engine) {
// Process all pending commands
while let Ok(cmd) = engine.command_rx.pop() {
match cmd {
Command::Play => engine.playing = true,
Command::Stop => {
engine.playing = false;
engine.playhead.store(0, Ordering::Relaxed);
}
Command::Seek(seconds) => {
let samples = (seconds * engine.sample_rate as f64) as u64;
engine.playhead.store(samples, Ordering::Relaxed);
}
}
}
// Render audio...
}
```
**New Dependencies:**
- `rtrb = "0.3"` (lock-free ringbuffer)
**Success Criteria:**
- Commands execute within 1 buffer period
- No audio glitches during seek
- Playhead position updates smoothly
---
### Phase 3: Multiple Audio Tracks (Week 3-4)
**Goal**: Play multiple audio files simultaneously
**Deliverables:**
- Track data structure
- Per-track volume control
- Mute/solo functionality
- Mix multiple tracks
**Core Implementation:**
```rust
struct Track {
id: u32,
audio_data: Vec<f32>,
volume: f32,
muted: bool,
solo: bool,
}
struct Engine {
tracks: Vec<Track>,
playhead: Arc<AtomicU64>,
command_rx: rtrb::Consumer<Command>,
sample_rate: u32,
mix_buffer: Vec<f32>,
}
enum Command {
Play,
Stop,
Seek(f64),
SetTrackVolume(u32, f32),
SetTrackMute(u32, bool),
SetTrackSolo(u32, bool),
}
fn audio_callback(data: &mut [f32], engine: &mut Engine) {
// Process commands...
if engine.playing {
// Clear mix buffer
engine.mix_buffer.fill(0.0);
// Check if any track is soloed
let any_solo = engine.tracks.iter().any(|t| t.solo);
// Mix all active tracks
for track in &engine.tracks {
let active = !track.muted && (!any_solo || track.solo);
if active {
mix_track(track, &mut engine.mix_buffer, engine.playhead, data.len());
}
}
// Copy mix to output
data.copy_from_slice(&engine.mix_buffer[..data.len()]);
}
}
fn mix_track(track: &Track, output: &mut [f32], playhead: u64, frames: usize) {
let start = playhead as usize;
let end = (start + frames).min(track.audio_data.len());
for (i, sample) in track.audio_data[start..end].iter().enumerate() {
output[i] += sample * track.volume;
}
}
```
**Success Criteria:**
- 4+ tracks play simultaneously without distortion
- Volume changes are smooth (no clicks)
- Solo/mute work correctly
- CPU usage remains reasonable
---
### Phase 4: Clips & Timeline (Week 4-5)
**Goal**: Place audio regions at different positions on timeline
**Deliverables:**
- Clip data structure
- Timeline-based playback
- Audio pool for shared audio data
- Multiple clips per track
**Core Implementation:**
```rust
struct Clip {
id: u32,
audio_pool_index: usize,
start_time: f64, // Seconds
duration: f64,
offset: f64, // Offset into audio file
gain: f32,
}
struct Track {
id: u32,
clips: Vec<Clip>,
volume: f32,
muted: bool,
solo: bool,
}
struct AudioPool {
files: Vec<Vec<f32>>,
}
fn render_track(
track: &Track,
output: &mut [f32],
pool: &AudioPool,
playhead_seconds: f64,
sample_rate: u32,
frames: usize,
) {
for clip in &track.clips {
let clip_start = clip.start_time;
let clip_end = clip.start_time + clip.duration;
// Check if clip is active in this time range
if playhead_seconds < clip_end &&
playhead_seconds + (frames as f64 / sample_rate as f64) > clip_start {
render_clip(clip, output, pool, playhead_seconds, sample_rate, frames);
}
}
}
fn render_clip(
clip: &Clip,
output: &mut [f32],
pool: &AudioPool,
playhead_seconds: f64,
sample_rate: u32,
frames: usize,
) {
let audio = &pool.files[clip.audio_pool_index];
// Calculate position within clip
let clip_position = playhead_seconds - clip.start_time + clip.offset;
let start_sample = (clip_position * sample_rate as f64) as usize;
// Calculate how many samples to copy
let samples_available = audio.len().saturating_sub(start_sample);
let samples_to_copy = samples_available.min(output.len());
// Mix into output
for i in 0..samples_to_copy {
output[i] += audio[start_sample + i] * clip.gain * clip.volume;
}
}
```
**Success Criteria:**
- Clips play at correct timeline positions
- Multiple clips per track work correctly
- Clips can overlap
- Audio pool prevents duplication
**UI Requirements:**
- Basic timeline view
- Drag clips to position them
- Visual representation of waveforms
---
### Phase 5: Effect Processing (Week 5-6)
**Goal**: Add gain/pan/simple effects to tracks
**Deliverables:**
- Effect trait
- Basic effects (gain, pan, simple EQ)
- Per-track effect chain
- Effect parameter control
**Core Implementation:**
```rust
trait Effect: Send {
fn process(&mut self, buffer: &mut [f32], channels: usize, sample_rate: u32);
fn set_parameter(&mut self, id: u32, value: f32);
fn get_parameter(&self, id: u32) -> f32;
fn reset(&mut self);
}
struct GainEffect {
gain_linear: f32,
}
impl Effect for GainEffect {
fn process(&mut self, buffer: &mut [f32], _channels: usize, _sample_rate: u32) {
for sample in buffer.iter_mut() {
*sample *= self.gain_linear;
}
}
fn set_parameter(&mut self, id: u32, value: f32) {
if id == 0 { // Gain in dB
self.gain_linear = 10.0_f32.powf(value / 20.0);
}
}
fn get_parameter(&self, id: u32) -> f32 {
if id == 0 {
20.0 * self.gain_linear.log10()
} else {
0.0
}
}
fn reset(&mut self) {}
}
struct Track {
id: u32,
clips: Vec<Clip>,
effects: Vec<Box<dyn Effect>>,
volume: f32,
muted: bool,
}
fn render_track(
track: &mut Track,
output: &mut [f32],
// ... other params
) {
// Render all clips...
// Apply effect chain
for effect in &mut track.effects {
effect.process(output, 2, sample_rate);
}
// Apply track volume
for sample in output.iter_mut() {
*sample *= track.volume;
}
}
```
**Additional Effects to Implement:**
```rust
struct PanEffect {
pan: f32, // -1.0 (left) to 1.0 (right)
}
struct SimpleEQ {
low_gain: f32,
mid_gain: f32,
high_gain: f32,
low_filter: BiquadFilter,
high_filter: BiquadFilter,
}
struct BiquadFilter {
b0: f32, b1: f32, b2: f32,
a1: f32, a2: f32,
x1: f32, x2: f32,
y1: f32, y2: f32,
}
```
**Success Criteria:**
- Effects process without distortion
- Multiple effects can chain
- Parameter changes are smooth
- No performance degradation
**Begin DSP Library:**
- Basic filters (lowpass, highpass, bandpass)
- Utilities (db to linear, frequency to coefficients)
---
### Phase 6: Hierarchical Tracks - Foundation (Week 6-7)
**Goal**: Introduce track hierarchy (groups) without full metatracks
**Deliverables:**
- TrackNode enum
- Group tracks
- Recursive rendering
- Parent-child relationships
**Core Implementation:**
```rust
enum TrackNode {
Audio(AudioTrack),
Group(GroupTrack),
}
struct AudioTrack {
id: u32,
clips: Vec<Clip>,
effects: Vec<Box<dyn Effect>>,
volume: f32,
parent: Option<u32>,
}
struct GroupTrack {
id: u32,
children: Vec<u32>,
effects: Vec<Box<dyn Effect>>,
volume: f32,
}
struct Project {
tracks: HashMap<u32, TrackNode>,
root_tracks: Vec<u32>,
audio_pool: AudioPool,
}
fn render_track_node(
node_id: u32,
project: &Project,
output: &mut [f32],
context: &RenderContext,
buffer_pool: &mut BufferPool,
) {
match &project.tracks[&node_id] {
TrackNode::Audio(track) => {
render_audio_track(track, output, &project.audio_pool, context);
}
TrackNode::Group(group) => {
// Get temp buffer from pool
let mut group_buffer = buffer_pool.acquire();
group_buffer.resize(output.len(), 0.0);
// Render all children
for child_id in &group.children {
render_track_node(
*child_id,
project,
&mut group_buffer,
context,
buffer_pool
);
}
// Apply group effects
for effect in &mut group.effects {
effect.process(&mut group_buffer, 2, context.sample_rate);
}
// Mix into output
for (out, group) in output.iter_mut().zip(group_buffer.iter()) {
*out += group * group.volume;
}
buffer_pool.release(group_buffer);
}
}
}
struct RenderContext {
playhead_seconds: f64,
sample_rate: u32,
tempo: f32,
}
```
**Success Criteria:**
- Can create groups of tracks
- Groups can nest (test 3-4 levels)
- Effects on groups affect all children
- No audio glitches from recursion
**Refactoring Required:**
- Migrate from `Vec<Track>` to `HashMap<TrackId, TrackNode>`
- Update all track access code
- Add parent tracking
---
### Phase 7: MIDI Support (Week 7-8)
**Goal**: Play MIDI through virtual instruments
**Deliverables:**
- MIDI data structures
- MIDI clip rendering
- Simple virtual instrument
- MIDI track type
**Core Implementation:**
```rust
struct MidiEvent {
timestamp: u64, // Sample position
status: u8,
data1: u8, // Note/CC number
data2: u8, // Velocity/value
}
struct MidiClip {
id: u32,
events: Vec<MidiEvent>,
start_time: f64,
duration: f64,
}
struct MidiTrack {
id: u32,
clips: Vec<MidiClip>,
instrument: Box<dyn Effect>, // Synth as effect
effects: Vec<Box<dyn Effect>>,
volume: f32,
parent: Option<u32>,
}
enum TrackNode {
Audio(AudioTrack),
Midi(MidiTrack),
Group(GroupTrack),
}
// Simple sine wave synth for testing
struct SimpleSynth {
voices: Vec<SynthVoice>,
sample_rate: f32,
}
struct SynthVoice {
active: bool,
note: u8,
velocity: u8,
phase: f32,
frequency: f32,
}
impl Effect for SimpleSynth {
fn process(&mut self, buffer: &mut [f32], channels: usize, sample_rate: u32) {
// Process active voices
for voice in &mut self.voices {
if voice.active {
for frame in buffer.chunks_mut(channels) {
let sample = (voice.phase * 2.0 * PI).sin()
* (voice.velocity as f32 / 127.0) * 0.3;
for channel in frame.iter_mut() {
*channel += sample;
}
voice.phase += voice.frequency / sample_rate as f32;
if voice.phase >= 1.0 {
voice.phase -= 1.0;
}
}
}
}
}
// Handle MIDI events via parameters
fn set_parameter(&mut self, id: u32, value: f32) {
match id {
0 => self.note_on(value as u8, 100), // Note on
1 => self.note_off(value as u8), // Note off
_ => {}
}
}
}
fn render_midi_track(
track: &mut MidiTrack,
output: &mut [f32],
context: &RenderContext,
frames: usize,
) {
// Collect MIDI events for this render period
let mut events_to_process = Vec::new();
for clip in &track.clips {
collect_events_in_range(
clip,
context.playhead_seconds,
frames,
context.sample_rate,
&mut events_to_process
);
}
// Sort by timestamp
events_to_process.sort_by_key(|e| e.timestamp);
// Process events through instrument
for event in events_to_process {
handle_midi_event(&mut track.instrument, event);
}
// Generate audio
track.instrument.process(output, 2, context.sample_rate);
// Apply effect chain
for effect in &mut track.effects {
effect.process(output, 2, context.sample_rate);
}
}
```
**Success Criteria:**
- Can load and play MIDI files
- Notes trigger at correct times
- Polyphony works (4+ notes)
- Timing is sample-accurate
**Dependencies:**
- `midly = "0.5"` (for MIDI file parsing)
---
### Phase 8: Full Metatracks (Week 8-9)
**Goal**: Add time transformation and metatrack-specific features
**Deliverables:**
- Metatrack type with transformations
- Time stretch functionality
- Offset capability
- Transform context propagation
**Core Implementation:**
```rust
struct Metatrack {
id: u32,
children: Vec<u32>,
effects: Vec<Box<dyn Effect>>,
// Transformation parameters
time_stretch: f32, // 0.5 = half speed, 2.0 = double speed
pitch_shift: f32, // Semitones (future feature)
offset: f64, // Time offset in seconds
volume: f32,
parent: Option<u32>,
}
enum TrackNode {
Audio(AudioTrack),
Midi(MidiTrack),
Metatrack(Metatrack),
Group(GroupTrack),
}
struct RenderContext {
global_position: u64, // Absolute sample position
local_position: u64, // Position within current scope
sample_rate: u32,
tempo: f32,
time_signature: (u32, u32),
time_stretch: f32, // Accumulated stretch
}
impl Metatrack {
fn transform_context(&self, ctx: RenderContext) -> RenderContext {
let offset_samples = (self.offset * ctx.sample_rate as f64) as u64;
let adjusted_position = ctx.local_position.saturating_sub(offset_samples);
let stretched_position = (adjusted_position as f64 / self.time_stretch as f64) as u64;
RenderContext {
global_position: ctx.global_position,
local_position: stretched_position,
sample_rate: ctx.sample_rate,
tempo: ctx.tempo * self.time_stretch,
time_signature: ctx.time_signature,
time_stretch: ctx.time_stretch * self.time_stretch,
}
}
}
fn render_metatrack(
meta: &Metatrack,
project: &Project,
output: &mut [f32],
context: RenderContext,
buffer_pool: &mut BufferPool,
) {
// Transform context for children
let child_context = meta.transform_context(context);
// Acquire buffer for submix
let mut submix = buffer_pool.acquire();
submix.resize(output.len(), 0.0);
// Render all children with transformed context
for child_id in &meta.children {
if let Some(child) = project.tracks.get(child_id) {
render_track_node(
*child_id,
project,
&mut submix,
child_context,
buffer_pool
);
}
}
// Apply metatrack effects
for effect in &mut meta.effects {
effect.process(&mut submix, 2, context.sample_rate);
}
// Mix into output
for (out, sub) in output.iter_mut().zip(submix.iter()) {
*out += sub * meta.volume;
}
buffer_pool.release(submix);
}
```
**Metatrack Operations:**
```rust
impl Project {
fn create_metatrack_from_selection(&mut self, track_ids: Vec<u32>) -> u32 {
let metatrack_id = self.next_id();
let metatrack = Metatrack {
id: metatrack_id,
children: track_ids.clone(),
effects: Vec::new(),
time_stretch: 1.0,
pitch_shift: 0.0,
offset: 0.0,
volume: 1.0,
parent: None,
};
// Update parent references
for track_id in track_ids {
if let Some(track) = self.tracks.get_mut(&track_id) {
set_parent(track, Some(metatrack_id));
}
}
self.tracks.insert(metatrack_id, TrackNode::Metatrack(metatrack));
self.root_tracks.push(metatrack_id);
metatrack_id
}
fn ungroup_metatrack(&mut self, metatrack_id: u32) {
if let Some(TrackNode::Metatrack(meta)) = self.tracks.get(&metatrack_id) {
let children = meta.children.clone();
// Remove parent from children
for child_id in children {
if let Some(track) = self.tracks.get_mut(&child_id) {
set_parent(track, None);
}
self.root_tracks.push(child_id);
}
// Remove metatrack
self.tracks.remove(&metatrack_id);
self.root_tracks.retain(|&id| id != metatrack_id);
}
}
}
```
**Success Criteria:**
- Can create metatracks from track selection
- Time stretch affects all children
- Offset shifts children in time
- Can nest metatracks 5+ levels deep
- Performance remains acceptable
---
### Phase 9: Polish & Optimization (Week 9-11)
#### 9a. Buffer Pool Optimization (Week 9)
**Goal**: Eliminate allocations in audio thread
```rust
struct BufferPool {
buffers: Vec<Vec<f32>>,
available: Vec<usize>,
buffer_size: usize,
total_allocations: AtomicUsize,
}
impl BufferPool {
fn new(count: usize, size: usize) -> Self {
let mut buffers = Vec::with_capacity(count);
let mut available = Vec::with_capacity(count);
for i in 0..count {
buffers.push(vec![0.0; size]);
available.push(i);
}
BufferPool {
buffers,
available,
buffer_size: size,
total_allocations: AtomicUsize::new(0),
}
}
fn acquire(&mut self) -> Vec<f32> {
if let Some(idx) = self.available.pop() {
let mut buf = std::mem::take(&mut self.buffers[idx]);
buf.fill(0.0);
buf
} else {
self.total_allocations.fetch_add(1, Ordering::Relaxed);
vec![0.0; self.buffer_size]
}
}
fn release(&mut self, buffer: Vec<f32>) {
if buffer.len() == self.buffer_size {
let idx = self.buffers.len();
self.buffers.push(buffer);
self.available.push(idx);
}
}
}
```
**Success Criteria:**
- Zero allocations during steady-state playback
- Pool size auto-adjusts to actual usage
- Metrics show allocation count
#### 9b. Lock-Free State Updates (Week 10)
**Goal**: Replace Mutex with triple-buffering for project state
```rust
struct TripleBuffer<T> {
buffers: [T; 3],
write_idx: AtomicUsize,
read_idx: AtomicUsize,
}
impl<T: Clone> TripleBuffer<T> {
fn new(initial: T) -> Self {
TripleBuffer {
buffers: [initial.clone(), initial.clone(), initial],
write_idx: AtomicUsize::new(0),
read_idx: AtomicUsize::new(0),
}
}
// Called from control thread
fn write(&mut self, value: T) {
let write_idx = self.write_idx.load(Ordering::Acquire);
let next_idx = (write_idx + 1) % 3;
self.buffers[next_idx] = value;
self.write_idx.store(next_idx, Ordering::Release);
}
// Called from audio thread
fn read(&self) -> &T {
let read_idx = self.read_idx.load(Ordering::Acquire);
let write_idx = self.write_idx.load(Ordering::Acquire);
if read_idx != write_idx {
self.read_idx.store(write_idx, Ordering::Release);
}
&self.buffers[read_idx]
}
}
```
**Success Criteria:**
- No locks in audio thread
- State updates propagate within 1-2 buffers
- No audio glitches during updates
#### 9c. Disk Streaming (Week 11)
**Goal**: Stream large audio files that don't fit in RAM
```rust
struct StreamingFile {
id: FileId,
path: PathBuf,
channels: u32,
sample_rate: u32,
total_frames: u64,
// Streaming state
buffer_rx: rtrb::Consumer<AudioChunk>,
current_chunk: Option<AudioChunk>,
chunk_offset: usize,
}
struct AudioChunk {
start_frame: u64,
data: Vec<f32>,
}
// Background streaming thread
fn streaming_thread(
file_path: PathBuf,
request_rx: Receiver<StreamRequest>,
chunk_tx: rtrb::Producer<AudioChunk>,
) {
let mut file = File::open(file_path).unwrap();
let mut decoder = /* create decoder */;
loop {
if let Ok(request) = request_rx.try_recv() {
// Seek to requested position
decoder.seek(request.frame);
}
// Read chunk
let chunk = decoder.read_frames(CHUNK_SIZE);
// Send to audio thread
let _ = chunk_tx.push(AudioChunk {
start_frame: current_frame,
data: chunk,
});
current_frame += CHUNK_SIZE;
}
}
```
**Success Criteria:**
- Can play files larger than RAM
- No dropouts during streaming
- Seek works smoothly
- Multiple streaming files simultaneously
---
### Phase 10: Advanced Features (Week 11+)
#### Automation
```rust
struct AutomationLane {
parameter_id: u32,
points: Vec<AutomationPoint>,
}
struct AutomationPoint {
time: f64,
value: f32,
curve: CurveType,
}
enum CurveType {
Linear,
Exponential,
SCurve,
}
```
#### Plugin Hosting (VST/CLAP)
```rust
struct PluginHost {
scanner: PluginScanner,
instances: HashMap<PluginId, PluginInstance>,
}
struct PluginInstance {
id: PluginId,
plugin_type: PluginType,
handle: *mut c_void,
parameters: Vec<Parameter>,
}
enum PluginType {
VST3,
CLAP,
}
```
#### Project Save/Load
```rust
#[derive(Serialize, Deserialize)]
struct ProjectFile {
version: String,
metadata: ProjectMetadata,
tracks: Vec<SerializedTrack>,
audio_files: Vec<AudioFileReference>,
tempo_map: TempoMap,
}
```
#### Undo/Redo System
```rust
trait Command {
fn execute(&mut self, project: &mut Project);
fn undo(&mut self, project: &mut Project);
}
struct CommandHistory {
commands: Vec<Box<dyn Command>>,
position: usize,
}
```
---
## Technical Specifications
### Performance Targets
- **Latency**: < 10ms (varies by buffer size and sample rate)
- **CPU Usage**: < 50% for 32 tracks with effects at 44.1kHz
- **Track Count**: Support 64+ tracks without performance degradation
- **Nesting Depth**: 10 levels of metatrack nesting
- **Plugin Count**: 8+ plugins per track
### Buffer Sizes
- **Audio Callback**: 128-512 frames (adjustable)
- **Streaming Chunk**: 8192 frames
- **Ring Buffer**: 8192 samples (UI Audio commands)
### Sample Rates
- **Supported**: 44.1kHz, 48kHz, 88.2kHz, 96kHz
- **Default**: 48kHz
- **Internal Processing**: Always at project sample rate
### Memory Budget
- **Audio Pool Cache**: 1GB default, configurable
- **Buffer Pool**: 50 buffers × 4096 samples × 4 bytes = 800KB
- **Per-Track Overhead**: < 1KB
### Thread Model
1. **UI Thread**: User interaction, visualization
2. **Control Thread**: Project state management, file I/O
3. **Audio Thread**: Real-time processing (cpal callback)
4. **Streaming Thread(s)**: Disk I/O for large files
### Data Format
- **Internal Audio**: 32-bit float, interleaved
- **File Support**: WAV, FLAC, MP3, OGG via symphonia
- **MIDI**: Standard MIDI File Format
- **Project Files**: JSON or MessagePack
---
## Testing Strategy
### Unit Tests
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clip_rendering_at_position() {
let clip = Clip {
id: 0,
audio_pool_index: 0,
start_time: 1.0, // Starts at 1 second
duration: 2.0,
offset: 0.0,
gain: 1.0,
};
let pool = AudioPool {
files: vec![vec![1.0; 96000]], // 2 seconds at 48kHz
};
let mut output = vec![0.0; 4800]; // 0.1 seconds
render_clip(&clip, &mut output, &pool, 1.5, 48000, 4800);
assert!(output.iter().any(|&s| s != 0.0));
}
#[test]
fn test_metatrack_time_stretch() {
let mut context = RenderContext {
global_position: 48000,
local_position: 48000,
sample_rate: 48000,
tempo: 120.0,
time_signature: (4, 4),
time_stretch: 1.0,
};
let metatrack = Metatrack {
id: 0,
children: vec![],
effects: vec![],
time_stretch: 0.5, // Half speed
pitch_shift: 0.0,
offset: 0.0,
volume: 1.0,
parent: None,
};
let child_context = metatrack.transform_context(context);
assert_eq!(child_context.time_stretch, 0.5);
assert_eq!(child_context.tempo, 60.0);
}
#[test]
fn test_buffer_pool_no_allocations() {
let mut pool = BufferPool::new(10, 1024);
let buf1 = pool.acquire();
let buf2 = pool.acquire();
pool.release(buf1);
pool.release(buf2);
let buf3 = pool.acquire();
let buf4 = pool.acquire();
// Should reuse buffers, no new allocations
assert_eq!(pool.total_allocations.load(Ordering::Relaxed), 0);
}
}
```
### Integration Tests
```rust
#[test]
fn test_full_playback_pipeline() {
// Setup
let (cmd_tx, cmd_rx) = rtrb::RingBuffer::new(256);
let (evt_tx, evt_rx) = rtrb::RingBuffer::new(256);
let mut engine = AudioEngine::new(48000, cmd_rx, evt_tx);
engine.load_audio_file("test.wav");
// Start playback
cmd_tx.push(Command::Play).unwrap();
// Render some audio
let mut output = vec![0.0; 4800];
engine.process(&mut output);
// Verify audio was rendered
assert!(output.iter().any(|&s| s.abs() > 0.001));
// Check events
if let Ok(AudioEvent::PlaybackPosition(pos)) = evt_rx.pop() {
assert!(pos > 0.0);
}
}
#[test]
fn test_nested_metatrack_rendering() {
let mut project = Project::new(48000);
// Create structure: Metatrack1 -> Metatrack2 -> AudioTrack
let audio_id = project.add_audio_track();
let meta2_id = project.create_metatrack_from_selection(vec![audio_id]);
let meta1_id = project.create_metatrack_from_selection(vec![meta2_id]);
// Apply transformations
project.set_metatrack_time_stretch(meta1_id, 0.5);
project.set_metatrack_time_stretch(meta2_id, 2.0);
// Render
let mut output = vec![0.0; 4800];
project.render(&mut output, 0.0);
// Effective stretch should be 0.5 * 2.0 = 1.0 (normal speed)
assert!(output.iter().any(|&s| s != 0.0));
}
```
### Performance Tests
```rust
#[bench]
fn bench_render_32_tracks(b: &mut Bencher) {
let mut engine = create_engine_with_tracks(32);
let mut output = vec![0.0; 512 * 2];
b.iter(|| {
engine.process(&mut output);
});
}
#[bench]
fn bench_metatrack_nesting_10_levels(b: &mut Bencher) {
let mut project = create_nested_metatracks(10);
let mut output = vec![0.0; 512 * 2];
b.iter(|| {
project.render(&mut output, 0.0);
});
}
```
### Audio Quality Tests
- **THD+N**: Total Harmonic Distortion + Noise < 0.01%
- **Frequency Response**: Flat ±0.1dB 20Hz-20kHz
- **Click Detection**: No clicks during parameter changes
- **Timing Accuracy**: MIDI events within ±1 sample
### Stress Tests
- **Long Sessions**: 8+ hours continuous playback
- **Many Tracks**: 128 tracks, 8 effects each
- **Deep Nesting**: 20 levels of metatrack nesting
- **Rapid Commands**: 1000 commands/second
- **Large Files**: 1GB+ audio files streaming
---
## Recommended Crates
### Core Audio
- `cpal = "0.15"` - Audio I/O
- `symphonia = "0.5"` - Audio decoding
- `rubato = "0.14"` - Sample rate conversion
### Concurrency
- `rtrb = "0.3"` - Lock-free ring buffers
- `crossbeam = "0.8"` - Additional concurrency tools
- `parking_lot = "0.12"` - Better mutexes (non-realtime)
### DSP
- `realfft = "3.3"` - FFT for spectral processing
- `biquad = "0.4"` - IIR filters
### Serialization
- `serde = { version = "1.0", features = ["derive"] }`
- `serde_json = "1.0"` or `rmp-serde = "1.1"` (MessagePack)
### File I/O
- `midly = "0.5"` - MIDI file parsing
- `hound = "3.5"` - WAV file writing
### Future
- `vst3-sys` or `clack` - Plugin hosting
- `egui = "0.24"` - Immediate mode GUI (if building UI in Rust)
---
## Project Structure
```
daw-backend/
├── Cargo.toml
├── src/
│ ├── main.rs
│ ├── lib.rs
│ │
│ ├── audio/
│ │ ├── mod.rs
│ │ ├── engine.rs # Audio engine, main processing loop
│ │ ├── track.rs # Track types (Audio, MIDI, Metatrack)
│ │ ├── clip.rs # Clip management
│ │ ├── pool.rs # Audio pool
│ │ ├── buffer_pool.rs # Buffer allocation pool
│ │ └── render.rs # Rendering functions
│ │
│ ├── project/
│ │ ├── mod.rs
│ │ ├── project.rs # Project state
│ │ ├── hierarchy.rs # Track hierarchy management
│ │ ├── operations.rs # Project operations (add track, etc.)
│ │ └── serialization.rs # Save/load
│ │
│ ├── effects/
│ │ ├── mod.rs
│ │ ├── trait.rs # Effect trait
│ │ ├── gain.rs
│ │ ├── pan.rs
│ │ ├── eq.rs
│ │ └── synth.rs # Simple synth for MIDI
│ │
│ ├── dsp/
│ │ ├── mod.rs
│ │ ├── filters.rs # Biquad, etc.
│ │ ├── envelope.rs # ADSR
│ │ ├── oscillator.rs
│ │ └── utils.rs # DB conversion, etc.
│ │
│ ├── io/
│ │ ├── mod.rs
│ │ ├── audio_file.rs # Audio file loading
│ │ ├── midi_file.rs # MIDI file loading
│ │ └── streaming.rs # Disk streaming
│ │
│ ├── command/
│ │ ├── mod.rs
│ │ ├── types.rs # Command/Event enums
│ │ └── queue.rs # Command queue management
│ │
│ └── ui/
│ ├── mod.rs
│ └── bridge.rs # UI-Audio communication
├── tests/
│ ├── integration_tests.rs
│ └── audio_quality_tests.rs
└── benches/
└── performance.rs
```
---
## Conclusion
This architecture provides:
1. **Incremental Development**: Each phase builds on the last without requiring rewrites
2. **Real-time Safety**: Lock-free, allocation-free audio thread from the start
3. **Flexibility**: Hierarchical tracks support simple projects and complex arrangements
4. **Scalability**: Architecture handles 64+ tracks with deep nesting
5. **Extensibility**: Effect trait makes plugin hosting straightforward
The roadmap gets you from "hello audio" to a full-featured DAW in 11 weeks, with each phase delivering working, testable functionality.
**Next Steps:**
1. Set up Rust project with cpal
2. Implement Phase 1 (single file playback)
3. Add comprehensive tests
4. Profile and optimize as needed
5. Continue through phases sequentially
Good luck building your DAW!