DSP Graph Architecture
The core design of bbx_audio's DSP processing system.
Overview
bbx_audio uses a directed acyclic graph (DAG) architecture where:
- Blocks are processing nodes
- Connections define signal flow
- Topological sorting determines execution order
- Pre-allocated buffers enable real-time processing
Key Components
Graph
The Graph struct manages:
#![allow(unused)] fn main() { pub struct Graph<S: Sample> { blocks: Vec<BlockType<S>>, // All DSP blocks connections: Vec<Connection>, // Block connections execution_order: Vec<BlockId>, // Sorted processing order output_block: Option<BlockId>, // Final output audio_buffers: Vec<AudioBuffer<S>>, // Pre-allocated buffers modulation_values: Vec<S>, // Per-block modulation } }
GraphBuilder
Fluent API for construction:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{GainBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let gain = builder.add(GainBlock::new(-6.0, None)); builder.connect(osc, 0, gain, 0); let graph = builder.build(); }
Connection
Describes signal routing:
#![allow(unused)] fn main() { pub struct Connection { pub from: BlockId, // Source block pub from_output: usize, // Output port pub to: BlockId, // Destination block pub to_input: usize, // Input port } }
Processing Pipeline
- Clear buffers - Zero all audio buffers
- Execute blocks - Process in topological order
- Collect modulation - Gather modulator outputs
- Copy output - Transfer to user buffers
#![allow(unused)] fn main() { pub fn process_buffers(&mut self, output_buffers: &mut [&mut [S]]) { // Clear all buffers for buffer in &mut self.audio_buffers { buffer.zeroize(); } // Process blocks in order for block_id in &self.execution_order { self.process_block(*block_id); self.collect_modulation_values(*block_id); } // Copy to output self.copy_to_output_buffer(output_buffers); } }
Design Decisions
Pre-allocation
All buffers are allocated during prepare_for_playback():
- No allocations during processing
- Fixed buffer sizes
- Predictable memory usage
Stack-Based I/O
Input/output slices use stack allocation:
#![allow(unused)] fn main() { const MAX_BLOCK_INPUTS: usize = 8; const MAX_BLOCK_OUTPUTS: usize = 8; let mut input_slices: StackVec<&[S], MAX_BLOCK_INPUTS> = StackVec::new(); }
Buffer Indexing
Each block has a contiguous range of buffers:
#![allow(unused)] fn main() { fn get_buffer_index(&self, block_id: BlockId, output_index: usize) -> usize { self.block_buffer_start[block_id.0] + output_index } }
Related Topics
- Topological Sorting - Execution order algorithm
- Buffer Management - Buffer allocation strategy
- Real-Time Safety - Audio thread constraints