bbx_audio
A Rust workspace for audio DSP with C FFI bindings for JUCE plugin integration.
Note: These crates are still in early development. Expect breaking changes in some releases.
What is bbx_audio?
bbx_audio is a collection of Rust crates designed for real-time audio digital signal processing (DSP). It provides a graph-based architecture for building audio processing chains, with first-class support for integrating Rust DSP code into C++ audio applications like JUCE plugins.
Crates
| Crate | Description |
|---|---|
bbx_core | Error types and foundational utilities |
bbx_dsp | DSP graph system, blocks, and PluginDsp trait |
bbx_plugin | C FFI bindings for JUCE integration |
bbx_file | Audio file I/O (WAV) |
bbx_midi | MIDI message parsing and streaming |
bbx_draw | Audio visualization primitives for nannou |
bbx_sandbox | Examples and testing playground |
Architecture Overview
bbx_core (foundational utilities)
└── bbx_dsp (DSP graph system)
├── bbx_file (audio file I/O via hound/wavers)
└── bbx_plugin (FFI bindings)
bbx_midi (MIDI streaming via midir, independent)
The core DSP system is built around a Graph of connected Blocks:
Blocktrait: Defines the DSP processing interface withprocess(), input/output counts, and modulation outputsBlockTypeenum: Wraps all concrete block implementations (oscillators, effects, I/O, modulators)Graph: Manages block connections, topological sorting for execution order, and buffer allocationGraphBuilder: Fluent API for constructing DSP graphs
Who is this documentation for?
Audio Programmers
If you're exploring Rust for audio software and want to understand real-time DSP patterns:
- Quick Start - Build your first DSP graph
- Graph Architecture - Core design patterns
- Real-Time Safety - Allocation-free, lock-free processing
Plugin Developers
If you want to use Rust for your audio plugin DSP while keeping your UI and plugin framework in C++/JUCE, start with:
- Quick Start - Get a simple graph running
- JUCE Plugin Integration - Full integration guide
- Parameter System - Managing plugin parameters
Game Audio Developers
If you're building audio systems for games and want real-time safe DSP in Rust:
- Quick Start - Build your first DSP graph
- Real-Time Safety - Allocation-free, lock-free processing
- Graph Architecture - Efficient audio routing
Experimental Musicians
If you're building instruments, exploring synthesis, or experimenting with sound design:
- Terminal Synthesizer - Build a MIDI-controlled synth from scratch
- Parameter Modulation - LFOs, envelopes, and modulation routing
- MIDI Integration - Connect keyboards and controllers
Generative Artists
If you're creating sound installations, audio visualizations, or experimenting with procedural audio:
- Quick Start - Build your first DSP graph
- Real-Time Visualization - Visualize audio with nannou
- Sketch Discovery - Manage and organize visual sketches
Library Contributors
If you want to contribute to bbx_audio or understand its internals:
- Development Setup - Set up your environment
- DSP Graph Architecture - Understand the core design
- Adding New Blocks - Extend the block library
Quick Example
#![allow(unused)] fn main() { use bbx_dsp::{GraphBuilder, blocks::*}; // Build a simple oscillator -> gain -> output chain let graph = GraphBuilder::new() .add_block(OscillatorBlock::new(440.0, Waveform::Sine)) .add_block(GainBlock::new(-6.0, None)) .add_block(OutputBlock::new(2)) .connect(0, 0, 1, 0)? // Oscillator -> Gain .connect(1, 0, 2, 0)? // Gain -> Output .build()?; }
License
bbx_audio is licensed under the MIT License. See LICENSE for details.
Installation
Adding bbx_audio to Your Project
Add the crates you need to your Cargo.toml:
[dependencies]
bbx_dsp = "0.4.0"
bbx_core = "0.4.0"
For JUCE plugin integration, you'll only need:
[dependencies]
bbx_plugin = "0.4.0"
For audio file I/O:
[dependencies]
bbx_file = "0.4.0"
For MIDI support:
[dependencies]
bbx_midi = "0.4.0"
Using Git Dependencies
To use the latest development version:
[dependencies]
bbx_dsp = { git = "https://github.com/blackboxaudio/bbx_audio" }
bbx_plugin = { git = "https://github.com/blackboxaudio/bbx_audio" }
Platform-Specific Setup
Linux
Install required packages for audio I/O:
sudo apt install alsa libasound2-dev libssl-dev pkg-config
macOS
No additional dependencies required. CoreAudio is used automatically.
Windows
No additional dependencies required. WASAPI is used automatically.
Local Development
To develop against a local copy of bbx_audio, create .cargo/config.toml in your project:
[patch."https://github.com/blackboxaudio/bbx_audio"]
bbx_core = { path = "/path/to/bbx_audio/bbx_core" }
bbx_dsp = { path = "/path/to/bbx_audio/bbx_dsp" }
bbx_plugin = { path = "/path/to/bbx_audio/bbx_plugin" }
bbx_midi = { path = "/path/to/bbx_audio/bbx_midi" }
This file should be added to your .gitignore.
Verifying Installation
Create a simple test to verify everything is working:
use bbx_dsp::GraphBuilder; fn main() { let _graph = GraphBuilder::new(); println!("bbx_audio installed successfully!"); }
Run with:
cargo run
Quick Start
This guide walks you through creating your first DSP graph with bbx_audio.
Building a Simple Synthesizer
Let's create a sine wave oscillator with gain control:
use bbx_dsp::{ Graph, GraphBuilder, blocks::{OscillatorBlock, GainBlock, OutputBlock, Waveform}, context::DspContext, }; fn main() -> Result<(), Box<dyn std::error::Error>> { // Create a DSP context let context = DspContext::new(44100.0, 512, 2); // Build the graph let mut graph = GraphBuilder::new() .add_block(OscillatorBlock::new(440.0, Waveform::Sine)) // Block 0 .add_block(GainBlock::new(-6.0, None)) // Block 1 .add_block(OutputBlock::new(2)) // Block 2 .connect(0, 0, 1, 0)? // Oscillator output -> Gain input .connect(1, 0, 2, 0)? // Gain output -> Output block .build()?; // Prepare the graph graph.prepare(&context); // Process audio let mut output = vec![vec![0.0f32; 512]; 2]; let inputs: Vec<&[f32]> = vec![]; let mut outputs: Vec<&mut [f32]> = output.iter_mut().map(|v| v.as_mut_slice()).collect(); graph.process(&inputs, &mut outputs, &context); // output now contains 512 samples of a 440Hz sine wave at -6dB println!("Generated {} samples", output[0].len()); Ok(()) }
Understanding the Code
DspContext
The DspContext holds audio processing parameters:
#![allow(unused)] fn main() { let context = DspContext::new( 44100.0, // Sample rate in Hz 512, // Buffer size in samples 2, // Number of channels ); }
GraphBuilder
The GraphBuilder provides a fluent API for constructing DSP graphs:
#![allow(unused)] fn main() { let graph = GraphBuilder::new() .add_block(/* block */) // Returns block index .connect(from_block, from_port, to_block, to_port)? .build()?; }
Connections
Connections are made between block outputs and inputs using indices:
#![allow(unused)] fn main() { .connect(0, 0, 1, 0)? // Block 0, output 0 -> Block 1, input 0 }
Adding Effects
Let's add some effects to our oscillator:
#![allow(unused)] fn main() { use bbx_dsp::blocks::{PannerBlock, OverdriveBlock}; let mut graph = GraphBuilder::new() .add_block(OscillatorBlock::new(440.0, Waveform::Saw)) // 0: Oscillator .add_block(OverdriveBlock::new(0.7)) // 1: Overdrive .add_block(GainBlock::new(-12.0, None)) // 2: Gain .add_block(PannerBlock::new(0.0)) // 3: Panner (center) .add_block(OutputBlock::new(2)) // 4: Output .connect(0, 0, 1, 0)? // Osc -> Overdrive .connect(1, 0, 2, 0)? // Overdrive -> Gain .connect(2, 0, 3, 0)? // Gain -> Panner .connect(3, 0, 4, 0)? // Panner L -> Output .connect(3, 1, 4, 1)? // Panner R -> Output .build()?; }
Next Steps
- Building a Terminal Synthesizer - Listen to your DSP graph
- Creating a Simple Oscillator - Explore oscillator waveforms
- Adding Effects - Learn about effect blocks
- Parameter Modulation - Use LFOs to modulate parameters
- JUCE Integration - Integrate with JUCE plugins
Building from Source
This guide covers building bbx_audio from source for development or contribution.
Prerequisites
Rust Toolchain
bbx_audio requires Rust with the nightly toolchain for some development features:
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Add nightly toolchain
rustup toolchain install nightly
# Verify installation
rustc --version
cargo --version
Platform Dependencies
Linux
sudo apt install alsa libasound2-dev libssl-dev pkg-config
macOS / Windows
No additional dependencies required.
Clone and Build
# Clone the repository
git clone https://github.com/blackboxaudio/bbx_audio.git
cd bbx_audio
# Build all crates
cargo build --workspace
# Build in release mode
cargo build --workspace --release
Running Tests
# Run all tests
cargo test --workspace --release
Code Quality
Formatting
bbx_audio uses the nightly formatter:
cargo +nightly fmt
Linting
Clippy is used for linting:
cargo +nightly clippy
Running Examples
The bbx_sandbox crate contains example programs:
# List available examples
ls bbx_sandbox/examples/
# Run an example
cargo run --release --example <example_name> -p bbx_sandbox
Generating Documentation
Generate rustdoc documentation:
cargo doc --workspace --no-deps --open
Project Structure
bbx_audio/
├── bbx_core/ # Foundational utilities
├── bbx_dsp/ # DSP graph system
├── bbx_file/ # Audio file I/O
├── bbx_midi/ # MIDI handling
├── bbx_plugin/ # FFI bindings
├── bbx_sandbox/ # Examples
├── docs/ # This documentation
└── Cargo.toml # Workspace manifest
Build Configuration
The workspace uses Rust 2024 edition. Key settings in the root Cargo.toml:
[workspace]
resolver = "2"
members = [
"bbx_core",
"bbx_dsp",
"bbx_file",
"bbx_midi",
"bbx_plugin",
"bbx_sandbox",
]
Troubleshooting
"toolchain not found" Error
Install the nightly toolchain:
rustup toolchain install nightly
Audio Device Errors on Linux
Ensure ALSA development packages are installed:
sudo apt install alsa libasound2-dev
Slow Builds
Use release mode for faster runtime performance:
cargo build --release
For faster compile times during development, use debug mode:
cargo build
Your First DSP Graph
This tutorial walks you through creating your first audio processing graph with bbx_audio.
Prerequisites
Add bbx_dsp to your project:
[dependencies]
bbx_dsp = "0.1"
Creating a Graph
DSP graphs in bbx_audio are built using GraphBuilder:
use bbx_dsp::graph::GraphBuilder; fn main() { // Create a builder with: // - 44100 Hz sample rate // - 512 sample buffer size // - 2 channels (stereo) let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Build the graph let graph = builder.build(); }
Adding an Oscillator
Let's add a sine wave oscillator:
use bbx_dsp::{ blocks::OscillatorBlock, graph::GraphBuilder, waveform::Waveform, }; fn main() { let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Add a 440 Hz sine wave oscillator // The third parameter is an optional seed for the random number generator // (used by the Noise waveform for deterministic output) let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let graph = builder.build(); }
The add method returns a BlockId that you can use to connect blocks.
Processing Audio
Once you have a graph, you can process audio:
use bbx_dsp::{ blocks::OscillatorBlock, graph::GraphBuilder, waveform::Waveform, }; fn main() { let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let _osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let mut graph = builder.build(); // Create output buffers let mut left = vec![0.0f32; 512]; let mut right = vec![0.0f32; 512]; // Process into the buffers let mut outputs: [&mut [f32]; 2] = [&mut left, &mut right]; graph.process_buffers(&mut outputs); // left and right now contain 512 samples of a 440 Hz sine wave println!("First sample: {}", left[0]); }
Connecting Blocks
Blocks are connected using the connect method:
use bbx_dsp::{ blocks::{GainBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; fn main() { let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Add blocks let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let gain = builder.add(GainBlock::new(-6.0, None)); // -6 dB // Connect oscillator output 0 to gain input 0 builder.connect(osc, 0, gain, 0); let graph = builder.build(); }
Understanding Block IDs
Each block added to the graph gets a unique BlockId:
#![allow(unused)] fn main() { let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Block 0 let gain = builder.add(GainBlock::new(-6.0, None)); // Block 1 let pan = builder.add(PannerBlock::new(0.0)); // Block 2 }
Use these IDs when connecting blocks:
#![allow(unused)] fn main() { builder.connect(from_block, from_port, to_block, to_port); }
Complete Example
use bbx_dsp::{ blocks::{GainBlock, OscillatorBlock, PannerBlock}, graph::GraphBuilder, waveform::Waveform, }; fn main() { // Create builder let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Build a simple synth chain let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); let gain = builder.add(GainBlock::new(-12.0, None)); let pan = builder.add(PannerBlock::new(25.0)); // Slightly right // Connect: Osc -> Gain -> Panner builder.connect(osc, 0, gain, 0); builder.connect(gain, 0, pan, 0); // Build the graph let mut graph = builder.build(); // Process multiple buffers let mut left = vec![0.0f32; 512]; let mut right = vec![0.0f32; 512]; for _ in 0..100 { let mut outputs: [&mut [f32]; 2] = [&mut left, &mut right]; graph.process_buffers(&mut outputs); // Do something with the audio... } }
Next Steps
Choose your learning path:
- Build something immediately: Building a Terminal Synthesizer - Create a playable synth with audio output
- Deepen your understanding: Continue with the core concepts:
- Creating a Simple Oscillator - Explore different waveforms
- Adding Effects - Process audio with gain, panning, distortion
- Parameter Modulation - Animate parameters with LFOs
Building a Terminal Synthesizer
This tutorial shows you how to create a synthesizer that runs in your terminal, starting with a simple tone and building up to a real-time MIDI-controlled instrument.
Prerequisites
- Rust nightly toolchain installed
- Audio output device (speakers or headphones)
- USB MIDI keyboard (optional, for Part 3)
Prior knowledge: Before starting, review Your First DSP Graph for core graph concepts.
Note: This tutorial progressively introduces concepts. Parts 2-3 use techniques covered in more depth in Parameter Modulation and MIDI Integration.
Part 1: Basic Sine Wave Synth
Creating Your Project
Create a new Rust project:
cargo new my_synth
cd my_synth
Set up the nightly toolchain for this project:
rustup override set nightly
Configure Dependencies
Update your Cargo.toml:
[package]
name = "my_synth"
version = "0.1.0"
edition = "2024"
[dependencies]
bbx_dsp = "0.4.0"
rodio = "0.20.1"
Writing the Code
Replace the contents of src/main.rs with the following:
use std::time::Duration; use bbx_dsp::prelude::*; use rodio::{OutputStream, Source}; struct Signal { graph: Graph<f32>, buffers: Vec<Vec<f32>>, num_channels: usize, buffer_size: usize, sample_rate: u32, ch: usize, idx: usize, } impl Signal { fn new(graph: Graph<f32>) -> Self { let ctx = graph.context(); Self { buffers: (0..ctx.num_channels).map(|_| vec![0.0; ctx.buffer_size]).collect(), num_channels: ctx.num_channels, buffer_size: ctx.buffer_size, sample_rate: ctx.sample_rate as u32, graph, ch: 0, idx: 0, } } } impl Iterator for Signal { type Item = f32; fn next(&mut self) -> Option<f32> { if self.ch == 0 && self.idx == 0 { let mut refs: Vec<&mut [f32]> = self.buffers.iter_mut().map(|b| &mut b[..]).collect(); self.graph.process_buffers(&mut refs); } let sample = self.buffers[self.ch][self.idx]; self.ch += 1; if self.ch >= self.num_channels { self.ch = 0; self.idx = (self.idx + 1) % self.buffer_size; } Some(sample) } } impl Source for Signal { fn current_frame_len(&self) -> Option<usize> { None } fn channels(&self) -> u16 { self.num_channels as u16 } fn sample_rate(&self) -> u32 { self.sample_rate } fn total_duration(&self) -> Option<Duration> { None } } fn play(graph: Graph<f32>, seconds: u64) { let (_stream, handle) = OutputStream::try_default().unwrap(); handle.play_raw(Signal::new(graph).convert_samples()).unwrap(); std::thread::sleep(Duration::from_secs(seconds)); } fn create_graph() -> Graph<f32> { use bbx_dsp::blocks::{GainBlock, OscillatorBlock}; let mut builder = GraphBuilder::new(DEFAULT_SAMPLE_RATE, DEFAULT_BUFFER_SIZE, 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); builder.build() } fn main() { play(create_graph(), 3); }
Understanding the Code
Signal struct: Wraps a DSP Graph and implements the Iterator and Source traits required by rodio for audio playback. The play() function handles audio output setup.
create_graph(): Builds our synthesizer:
- Creates a
GraphBuilderwith 44.1kHz sample rate, 512-sample buffer, and stereo output - Adds a 440Hz sine wave oscillator (concert A)
- Adds a gain block at -6dB (half amplitude, for comfortable listening)
- Connects the oscillator to the gain block
main(): Creates the graph and plays it for 3 seconds.
Running Your Synth
Run your synthesizer with:
cargo run --release
You should hear a 3-second sine wave tone at 440Hz.
Part 2: Subtractive Synth Voice
Now let's build a more interesting synthesizer with an oscillator, envelope, filter, and VCA. This is the classic subtractive synthesis signal chain:
Oscillator -> VCA <- Envelope -> LowPassFilter -> Gain -> Output
The VCA (Voltage Controlled Amplifier) multiplies the oscillator's audio by the envelope's control signal, giving us attack/decay/sustain/release dynamics. See VcaBlock for more on this pattern.
Updated Dependencies
Add the bbx_dsp block import:
[dependencies]
bbx_dsp = "0.4.0"
rodio = "0.20.1"
Subtractive Synth Code
Replace create_graph() with:
#![allow(unused)] fn main() { use bbx_dsp::{ block::BlockId, blocks::{EnvelopeBlock, GainBlock, LowPassFilterBlock, OscillatorBlock, VcaBlock}, graph::{Graph, GraphBuilder}, context::{DEFAULT_SAMPLE_RATE, DEFAULT_BUFFER_SIZE}, waveform::Waveform, }; fn create_synth_voice() -> (Graph<f32>, BlockId, BlockId) { let mut builder = GraphBuilder::new(DEFAULT_SAMPLE_RATE, DEFAULT_BUFFER_SIZE, 2); // Sound source: sawtooth wave (rich harmonics for filtering) let oscillator_id = builder.add(OscillatorBlock::new(440.0, Waveform::Sawtooth, None)); // Amplitude envelope: controls volume over time // See: modulation.md#envelope-generator let envelope_id = builder.add(EnvelopeBlock::new( 0.01, // Attack: 10ms 0.1, // Decay: 100ms 0.7, // Sustain: 70% 0.3, // Release: 300ms )); // VCA: multiplies audio by envelope let vca_id = builder.add(VcaBlock::new()); // Low-pass filter: removes harsh high frequencies let filter_id = builder.add(LowPassFilterBlock::new(2000.0, 1.5)); // Output gain: -6dB for comfortable listening let gain_id = builder.add(GainBlock::new(-6.0, None)); // Connect the signal chain builder .connect(oscillator_id, 0, vca_id, 0) // Audio to VCA .connect(envelope_id, 0, vca_id, 1) // Envelope controls VCA .connect(vca_id, 0, filter_id, 0) // VCA to filter .connect(filter_id, 0, gain_id, 0); // Filter to output (builder.build(), oscillator_id, envelope_id) } }
The function returns the graph plus the oscillator and envelope block IDs, which we'll need in Part 3 to control the synth from MIDI.
Testing the Voice
Update main() to trigger the envelope:
fn main() { let (mut graph, _osc_id, env_id) = create_synth_voice(); // Trigger the envelope if let Some(bbx_dsp::block::BlockType::Envelope(env)) = graph.get_block_mut(env_id) { env.note_on(); } play(graph, 2); }
Run with cargo run --release and you'll hear the envelope shape the sound.
Part 3: Adding MIDI Input
Now let's make the synth respond to a MIDI keyboard. We'll use a lock-free ring buffer to safely pass MIDI messages from the input thread to the audio thread. See Lock-Free MIDI Buffer for background on this pattern.
Additional Dependencies
Update Cargo.toml:
[dependencies]
bbx_dsp = "0.4.0"
bbx_midi = "0.1.0"
rodio = "0.20.1"
midir = "0.11"
ctrlc = "3.4"
Voice State
Track the currently playing note for monophonic behavior:
#![allow(unused)] fn main() { struct VoiceState { current_note: Option<u8>, } impl VoiceState { fn new() -> Self { Self { current_note: None } } fn note_on(&mut self, note: u8) { self.current_note = Some(note); } fn note_off(&mut self, note: u8) -> bool { if self.current_note == Some(note) { self.current_note = None; true } else { false } } } }
MIDI Synth Struct
Build on the Signal struct pattern, adding MIDI handling:
#![allow(unused)] fn main() { use bbx_dsp::{ block::BlockId, buffer::{AudioBuffer, Buffer}, context::{DEFAULT_BUFFER_SIZE, DEFAULT_SAMPLE_RATE}, graph::{Graph, GraphBuilder}, waveform::Waveform, }; use bbx_midi::{MidiBufferConsumer, MidiMessage, MidiMessageStatus, midi_buffer}; use rodio::Source; use std::time::Duration; struct MidiSynth { graph: Graph<f32>, output_buffers: Vec<AudioBuffer<f32>>, voice_state: VoiceState, oscillator_id: BlockId, envelope_id: BlockId, midi_consumer: MidiBufferConsumer, sample_rate: u32, num_channels: usize, buffer_size: usize, channel_index: usize, sample_index: usize, } impl MidiSynth { fn new(midi_consumer: MidiBufferConsumer) -> Self { use bbx_dsp::blocks::{EnvelopeBlock, GainBlock, LowPassFilterBlock, OscillatorBlock, VcaBlock}; let sample_rate = DEFAULT_SAMPLE_RATE; let buffer_size = DEFAULT_BUFFER_SIZE; let num_channels = 2; let mut builder = GraphBuilder::new(sample_rate, buffer_size, num_channels); let oscillator_id = builder.add(OscillatorBlock::new(440.0, Waveform::Sawtooth, None)); let envelope_id = builder.add(EnvelopeBlock::new(0.01, 0.1, 0.7, 0.3)); let vca_id = builder.add(VcaBlock::new()); let filter_id = builder.add(LowPassFilterBlock::new(2000.0, 1.5)); let gain_id = builder.add(GainBlock::new(-6.0, None)); builder .connect(oscillator_id, 0, vca_id, 0) .connect(envelope_id, 0, vca_id, 1) .connect(vca_id, 0, filter_id, 0) .connect(filter_id, 0, gain_id, 0); let graph = builder.build(); let mut output_buffers = Vec::with_capacity(num_channels); for _ in 0..num_channels { output_buffers.push(AudioBuffer::new(buffer_size)); } Self { graph, output_buffers, voice_state: VoiceState::new(), oscillator_id, envelope_id, midi_consumer, sample_rate: sample_rate as u32, num_channels, buffer_size, channel_index: 0, sample_index: 0, } } } }
Processing MIDI Events
Poll the MIDI buffer at the start of each audio block. See MIDI Message Types for the full list of status codes.
#![allow(unused)] fn main() { impl MidiSynth { fn process_midi_events(&mut self) { while let Some(msg) = self.midi_consumer.try_pop() { match msg.get_status() { MidiMessageStatus::NoteOn => { let velocity = msg.get_velocity().unwrap_or(0); if velocity > 0 { if let Some(note) = msg.get_note_number() { self.voice_state.note_on(note); // get_note_frequency() converts MIDI note to Hz // See: midi.md#midi-to-frequency if let Some(freq) = msg.get_note_frequency() { self.set_oscillator_frequency(freq); } self.trigger_envelope(); } } else { self.handle_note_off(&msg); } } MidiMessageStatus::NoteOff => { self.handle_note_off(&msg); } _ => {} } } } fn handle_note_off(&mut self, msg: &MidiMessage) { if let Some(note) = msg.get_note_number() { if self.voice_state.note_off(note) { self.release_envelope(); } } } fn set_oscillator_frequency(&mut self, frequency: f32) { if let Some(bbx_dsp::block::BlockType::Oscillator(osc)) = self.graph.get_block_mut(self.oscillator_id) { osc.set_midi_frequency(frequency); } } fn trigger_envelope(&mut self) { if let Some(bbx_dsp::block::BlockType::Envelope(env)) = self.graph.get_block_mut(self.envelope_id) { env.note_on(); } } fn release_envelope(&mut self) { if let Some(bbx_dsp::block::BlockType::Envelope(env)) = self.graph.get_block_mut(self.envelope_id) { env.note_off(); } } } }
Audio Source Implementation
Implement Iterator and Source for rodio playback:
#![allow(unused)] fn main() { impl MidiSynth { fn process(&mut self) -> f32 { if self.channel_index == 0 && self.sample_index == 0 { self.process_midi_events(); let mut output_refs: Vec<&mut [f32]> = self.output_buffers.iter_mut().map(|b| b.as_mut_slice()).collect(); self.graph.process_buffers(&mut output_refs); } let sample = self.output_buffers[self.channel_index][self.sample_index]; self.channel_index += 1; if self.channel_index >= self.num_channels { self.channel_index = 0; self.sample_index += 1; self.sample_index %= self.buffer_size; } sample } } impl Iterator for MidiSynth { type Item = f32; fn next(&mut self) -> Option<Self::Item> { Some(self.process()) } } impl Source for MidiSynth { fn current_frame_len(&self) -> Option<usize> { None } fn channels(&self) -> u16 { self.num_channels as u16 } fn sample_rate(&self) -> u32 { self.sample_rate } fn total_duration(&self) -> Option<Duration> { None } } }
MIDI Input Setup
Connect to a MIDI port using midir. See Real-Time MIDI Input for more details.
#![allow(unused)] fn main() { use std::io::{Write, stdin, stdout}; use midir::{Ignore, MidiInput, MidiInputConnection}; use bbx_midi::MidiBufferProducer; fn setup_midi_input(mut producer: MidiBufferProducer) -> Option<MidiInputConnection<()>> { let mut midi_in = MidiInput::new("MIDI Synth Input").ok()?; midi_in.ignore(Ignore::None); let in_ports = midi_in.ports(); let in_port = match in_ports.len() { 0 => { println!("No MIDI input ports found."); return None; } 1 => { let port_name = midi_in.port_name(&in_ports[0]).unwrap_or_default(); println!("Using MIDI port: {port_name}"); in_ports[0].clone() } _ => { println!("\nAvailable MIDI input ports:"); for (idx, port) in in_ports.iter().enumerate() { let name = midi_in.port_name(port).unwrap_or_default(); println!(" {idx}: {name}"); } print!("\nSelect port: "); stdout().flush().ok()?; let mut input = String::new(); stdin().read_line(&mut input).ok()?; let idx: usize = input.trim().parse().ok()?; if idx >= in_ports.len() { println!("Invalid port selection."); return None; } let port_name = midi_in.port_name(&in_ports[idx]).unwrap_or_default(); println!("Using MIDI port: {port_name}"); in_ports[idx].clone() } }; midi_in.connect( &in_port, "midi-synth-input", move |_timestamp, message_bytes, _| { let message = MidiMessage::from(message_bytes); let _ = producer.try_send(message); }, (), ).ok() } }
Updated Main Function
use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; use rodio::OutputStream; fn main() { println!("BBX MIDI Synthesizer\n"); // Ctrl+C handler let running = Arc::new(AtomicBool::new(true)); let r = running.clone(); ctrlc::set_handler(move || { r.store(false, Ordering::SeqCst); }).expect("Error setting Ctrl-C handler"); // Create MIDI buffer (lock-free producer/consumer pair) let (producer, consumer) = midi_buffer(256); // Set up MIDI input let _midi_connection = match setup_midi_input(producer) { Some(conn) => conn, None => { println!("Failed to set up MIDI input."); return; } }; println!("\nSynth ready! Play notes on your MIDI keyboard."); println!("Press Ctrl+C to exit.\n"); // Create synth and start audio let synth = MidiSynth::new(consumer); let (_stream, stream_handle) = OutputStream::try_default().unwrap(); stream_handle.play_raw(synth.convert_samples()).unwrap(); // Wait for Ctrl+C while running.load(Ordering::SeqCst) { std::thread::sleep(Duration::from_millis(100)); } println!("\nShutting down..."); }
Running the MIDI Synth
cargo run --release
Connect a USB MIDI keyboard before running. The program will list available ports and let you select one. Play notes to hear the synthesizer respond.
The complete example is also available in the bbx_sandbox crate:
cargo run --release --example 06_midi_synth -p bbx_sandbox
Experimenting
Try modifying the code to explore different sounds:
Change the oscillator waveform:
#![allow(unused)] fn main() { let oscillator_id = builder.add(OscillatorBlock::new(440.0, Waveform::Square, None)); // Hollow, woody let oscillator_id = builder.add(OscillatorBlock::new(440.0, Waveform::Triangle, None)); // Soft, flute-like }
Adjust the envelope shape:
#![allow(unused)] fn main() { // Plucky sound: fast attack and decay, no sustain let envelope_id = builder.add(EnvelopeBlock::new(0.001, 0.2, 0.0, 0.1)); // Pad sound: slow attack and release let envelope_id = builder.add(EnvelopeBlock::new(0.5, 0.3, 0.8, 1.0)); }
Change the filter cutoff:
#![allow(unused)] fn main() { let filter_id = builder.add(LowPassFilterBlock::new(500.0, 2.0)); // Darker, more resonant let filter_id = builder.add(LowPassFilterBlock::new(4000.0, 0.7)); // Brighter, less resonant }
Next Steps
Now that you've built a working synthesizer:
- Parameter Modulation with LFOs - Add vibrato and filter sweeps (deeper coverage of Part 2 concepts)
- Working with Audio Files - Add sample playback to your synth
- VcaBlock Reference - VCA block API details
For the concepts used in Part 3:
- MIDI Integration - Complete MIDI API reference and patterns
Creating a Simple Oscillator
This tutorial explores the OscillatorBlock and its waveform options.
Prior knowledge: This tutorial builds on Your First DSP Graph, which covers
GraphBuilderbasics and block connections.
Available Waveforms
bbx_audio provides several waveform types:
#![allow(unused)] fn main() { use bbx_dsp::waveform::Waveform; let waveform = Waveform::Sine; // Pure sine wave let waveform = Waveform::Square; // Square wave (50% duty cycle) let waveform = Waveform::Saw; // Sawtooth wave let waveform = Waveform::Triangle; // Triangle wave let waveform = Waveform::Pulse; // Pulse wave (variable duty cycle) let waveform = Waveform::Noise; // White noise }
Adding Oscillators
Add an oscillator using GraphBuilder:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::OscillatorBlock, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // 440 Hz sine wave let sine_osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // 220 Hz sawtooth let saw_osc = builder.add(OscillatorBlock::new(220.0, Waveform::Saw, None)); }
Waveform Characteristics
Sine
Pure sinusoidal wave. No harmonics - the most "pure" tone.
Use for: Subharmonics, test tones, smooth modulation sources.
Square
Equal time spent at maximum and minimum values. Contains only odd harmonics.
Use for: Hollow/woody tones, classic synthesizer sounds.
Sawtooth
Ramps from minimum to maximum, then resets. Contains all harmonics.
Use for: Bright, buzzy sounds. Good starting point for subtractive synthesis.
Triangle
Linear ramp up, linear ramp down. Contains only odd harmonics with steep rolloff.
Use for: Softer tones than square, flute-like sounds.
Pulse
Like square, but with variable duty cycle. The pulse width affects the harmonic content.
Use for: Nasal, reedy sounds. Width modulation creates rich timbres.
Noise
Random samples. Contains all frequencies equally.
Use for: Percussion, wind sounds, adding texture.
Frequency Modulation
Use an LFO to modulate the oscillator frequency. For a deeper dive into modulation, see Parameter Modulation with LFOs.
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{LfoBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Add an LFO for vibrato (5 Hz, moderate depth) let lfo = builder.add(LfoBlock::new(5.0, 0.3, Waveform::Sine, None)); // Create oscillator let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Connect LFO to modulate frequency builder.modulate(lfo, osc, "frequency"); let graph = builder.build(); }
Polyphony
Create multiple oscillators for polyphonic sounds:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{GainBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // C major chord: C4, E4, G4 let c4 = builder.add(OscillatorBlock::new(261.63, Waveform::Sine, None)); let e4 = builder.add(OscillatorBlock::new(329.63, Waveform::Sine, None)); let g4 = builder.add(OscillatorBlock::new(392.00, Waveform::Sine, None)); // Mix them together with a gain block let mixer = builder.add(GainBlock::new(-9.0, None)); // -9 dB for headroom builder.connect(c4, 0, mixer, 0); builder.connect(e4, 0, mixer, 0); builder.connect(g4, 0, mixer, 0); let graph = builder.build(); }
Detuned Oscillators
Create a thicker sound with detuned oscillators:
#![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 base_freq = 440.0; let detune_cents = 7.0; // 7 cents detune // Calculate detuned frequencies let detune_factor = 2.0_f32.powf(detune_cents / 1200.0); let freq_up = base_freq * detune_factor; let freq_down = base_freq / detune_factor; // Three oscillators: center, up, down let osc_center = builder.add(OscillatorBlock::new(base_freq as f64, Waveform::Saw, None)); let osc_up = builder.add(OscillatorBlock::new(freq_up as f64, Waveform::Saw, None)); let osc_down = builder.add(OscillatorBlock::new(freq_down as f64, Waveform::Saw, None)); let mixer = builder.add(GainBlock::new(-9.0, None)); builder.connect(osc_center, 0, mixer, 0); builder.connect(osc_up, 0, mixer, 0); builder.connect(osc_down, 0, mixer, 0); let graph = builder.build(); }
Next Steps
- Adding Effects - Process oscillator output with effects
- Parameter Modulation - Animate parameters with LFOs
Adding Effects
This tutorial covers the effect blocks available in bbx_audio.
Prior knowledge: This tutorial assumes familiarity with:
- Your First DSP Graph - GraphBuilder and connections
- Creating a Simple Oscillator - Adding audio sources
Available Effects
GainBlock- Level control in dBPannerBlock- Stereo panningOverdriveBlock- Soft-clipping distortionDcBlockerBlock- DC offset removalChannelRouterBlock- Channel routing/manipulationChannelSplitterBlock- Split multi-channel to individual outputsChannelMergerBlock- Merge individual inputs to multi-channelLowPassFilterBlock- SVF-based low-pass filter
GainBlock
Control signal level in decibels:
#![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)); // -6 dB builder.connect(osc, 0, gain, 0); let graph = builder.build(); }
Common gain values:
0.0dB = unity (no change)-6.0dB = half amplitude-12.0dB = quarter amplitude+6.0dB = double amplitude (watch for clipping!)
PannerBlock
Position audio in the stereo field:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{OscillatorBlock, PannerBlock}, 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 pan = builder.add(PannerBlock::new(0.0)); // Center builder.connect(osc, 0, pan, 0); let graph = builder.build(); }
Pan values:
-100.0= Hard left0.0= Center+100.0= Hard right
The panner uses constant-power panning for natural-sounding transitions.
OverdriveBlock
Soft-clipping distortion for warmth and saturation:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{OscillatorBlock, OverdriveBlock}, 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)); // Overdrive with drive, level, tone, sample_rate let overdrive = builder.add(OverdriveBlock::new( 5.0, // Drive amount (higher = more distortion) 1.0, // Output level 0.7, // Tone (0.0-1.0, higher = brighter) 44100.0 // Sample rate )); builder.connect(osc, 0, overdrive, 0); let graph = builder.build(); }
DcBlockerBlock
Remove DC offset from signals:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{DcBlockerBlock, 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 dc_blocker = builder.add(DcBlockerBlock::new(true)); builder.connect(osc, 0, dc_blocker, 0); let graph = builder.build(); }
Use DC blockers after:
- Distortion effects
- Asymmetric waveforms
- External audio input
LowPassFilterBlock
SVF-based low-pass filter with resonance control:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{LowPassFilterBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let osc = builder.add(OscillatorBlock::new(110.0, Waveform::Sawtooth, None)); // Low-pass filter with cutoff and resonance (Q) let filter = builder.add(LowPassFilterBlock::new( 800.0, // Cutoff frequency in Hz 2.0 // Resonance (Q factor, 0.5-10.0) )); builder.connect(osc, 0, filter, 0); let graph = builder.build(); }
The filter cutoff can be modulated by an LFO for classic wah/sweep effects. See the 10_filter_modulation example.
ChannelSplitterBlock
Split multi-channel audio into individual mono outputs:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::ChannelSplitterBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Split stereo into two mono outputs let splitter = builder.add(ChannelSplitterBlock::new(2)); // splitter output 0 = left channel // splitter output 1 = right channel }
ChannelMergerBlock
Merge individual mono inputs into multi-channel output:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::ChannelMergerBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Merge two mono inputs into stereo let merger = builder.add(ChannelMergerBlock::new(2)); // merger input 0 = left channel // merger input 1 = right channel }
Parallel Processing with Split/Merge
Process channels independently then recombine:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{ ChannelMergerBlock, ChannelSplitterBlock, LowPassFilterBlock, OscillatorBlock, PannerBlock, }, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let osc = builder.add(OscillatorBlock::new(110.0, Waveform::Sawtooth, None)); let panner = builder.add(PannerBlock::new(0.0)); // Split stereo signal let splitter = builder.add(ChannelSplitterBlock::new(2)); // Different filters for each channel let filter_left = builder.add(LowPassFilterBlock::new(500.0, 2.0)); // Darker let filter_right = builder.add(LowPassFilterBlock::new(2000.0, 2.0)); // Brighter // Merge back to stereo let merger = builder.add(ChannelMergerBlock::new(2)); // Build chain builder.connect(osc, 0, panner, 0); builder.connect(panner, 0, splitter, 0); builder.connect(panner, 1, splitter, 1); builder.connect(splitter, 0, filter_left, 0); builder.connect(splitter, 1, filter_right, 0); builder.connect(filter_left, 0, merger, 0); builder.connect(filter_right, 0, merger, 1); let graph = builder.build(); }
See the 11_channel_split_merge example for a complete demonstration.
Building Effect Chains
Chain multiple effects together using the same connection pattern from Your First DSP Graph:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{DcBlockerBlock, GainBlock, OscillatorBlock, OverdriveBlock, PannerBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Source let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); // Effect chain let overdrive = builder.add(OverdriveBlock::new(3.0, 1.0, 0.8, 44100.0)); let dc_blocker = builder.add(DcBlockerBlock::new(true)); let gain = builder.add(GainBlock::new(-6.0, None)); let pan = builder.add(PannerBlock::new(0.0)); // Connect: Osc -> Overdrive -> DC Blocker -> Gain -> Pan builder.connect(osc, 0, overdrive, 0); builder.connect(overdrive, 0, dc_blocker, 0); builder.connect(dc_blocker, 0, gain, 0); builder.connect(gain, 0, pan, 0); let graph = builder.build(); }
Parallel Effects
Route signals to multiple effects in parallel:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{GainBlock, OscillatorBlock, OverdriveBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); // Two parallel paths let clean_gain = builder.add(GainBlock::new(-6.0, None)); let dirty_overdrive = builder.add(OverdriveBlock::new(8.0, 1.0, 0.5, 44100.0)); // Mix them back together let mix = builder.add(GainBlock::new(-3.0, None)); // Split to both paths builder.connect(osc, 0, clean_gain, 0); builder.connect(osc, 0, dirty_overdrive, 0); // Merge at mix builder.connect(clean_gain, 0, mix, 0); builder.connect(dirty_overdrive, 0, mix, 0); let graph = builder.build(); }
Effect Order Matters
The order of effects changes the sound:
Osc -> Overdrive -> Gain // Distort first, then control level
Osc -> Gain -> Overdrive // Control level into distortion (changes character)
General guidelines:
- Gain staging - Control levels before distortion
- Distortion - Apply saturation
- DC blocking - Remove offset after distortion
- EQ/Filtering - Shape the tone
- Panning - Position in stereo field
- Final gain - Set output level
Next Steps
- Parameter Modulation - Animate effect parameters
- Working with Audio Files - Process real audio
Parameter Modulation with LFOs
This tutorial covers using LFOs and envelopes to modulate block parameters.
Prior knowledge: This tutorial builds on:
- Your First DSP Graph - GraphBuilder basics
- Adding Effects - GainBlock for tremolo examples
Low-Frequency Oscillators (LFOs)
LFOs generate control signals for modulating parameters like pitch, volume, and filter cutoff.
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::LfoBlock, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Create an LFO at 4 Hz with 0.5 depth // Parameters: frequency (Hz), depth (0.0-1.0), waveform, optional seed let lfo = builder.add(LfoBlock::new(4.0, 0.5, Waveform::Sine, None)); }
Vibrato (Pitch Modulation)
Modulate oscillator frequency for vibrato using the modulate() method:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{LfoBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // LFO for vibrato (5 Hz, moderate depth) let vibrato_lfo = builder.add(LfoBlock::new(5.0, 0.3, Waveform::Sine, None)); // Oscillator let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Connect LFO to modulate oscillator frequency builder.modulate(vibrato_lfo, osc, "frequency"); let graph = builder.build(); }
Typical vibrato settings:
- Rate: 4-7 Hz
- Depth: 0.1-0.5 for subtle vibrato
- Parameter: "frequency" or "pitch_offset"
Tremolo (Amplitude Modulation)
Modulate gain for tremolo effect:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{GainBlock, LfoBlock, 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)); // LFO for tremolo (6 Hz, full depth) let tremolo_lfo = builder.add(LfoBlock::new(6.0, 1.0, Waveform::Sine, None)); // Gain block let gain = builder.add(GainBlock::new(-6.0, None)); // Connect oscillator to gain builder.connect(osc, 0, gain, 0); // Modulate gain level with LFO builder.modulate(tremolo_lfo, gain, "level"); let graph = builder.build(); }
LFO Parameters
| Parameter | Type | Range | Description |
|---|---|---|---|
| frequency | f64 | 0.01 - max* | Rate in Hz |
| depth | f64 | 0.0 - 1.0 | Modulation intensity |
| seed | Option<u64> | Any | For deterministic output |
*Max frequency is sample_rate / (2 * buffer_size) due to control-rate operation (e.g., ~43 Hz at 44.1 kHz with 512-sample buffers).
Envelope Generator
ADSR envelopes control how parameters change over time. For a practical application with VCA, see Building a Terminal Synthesizer - Part 2.
#![allow(unused)] fn main() { use bbx_dsp::{blocks::EnvelopeBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Create an ADSR envelope // Parameters: attack, decay, sustain (level), release (all in seconds except sustain) let envelope = builder.add(EnvelopeBlock::new( 0.01, // Attack: 10ms 0.1, // Decay: 100ms 0.7, // Sustain: 70% 0.3, // Release: 300ms )); }
Combining Modulation Sources
Layer multiple modulation sources:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{GainBlock, LfoBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Slow LFO for overall movement let slow_lfo = builder.add(LfoBlock::new(0.5, 0.3, Waveform::Sine, None)); // Fast LFO for vibrato let fast_lfo = builder.add(LfoBlock::new(5.0, 0.2, Waveform::Sine, None)); // Oscillator with vibrato let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); builder.modulate(fast_lfo, osc, "frequency"); // Gain with slow amplitude modulation let gain = builder.add(GainBlock::new(-6.0, None)); builder.connect(osc, 0, gain, 0); builder.modulate(slow_lfo, gain, "level"); let graph = builder.build(); }
Modulation Depth
Control modulation intensity through the LFO's depth parameter:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::LfoBlock, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Subtle vibrato: low depth let subtle_lfo = builder.add(LfoBlock::new(5.0, 0.1, Waveform::Sine, None)); // Intense wobble: high depth let intense_lfo = builder.add(LfoBlock::new(2.0, 1.0, Waveform::Sine, None)); }
Practical Examples
Filter Sweep (Wah Effect)
Modulate filter cutoff for classic wah/sweep effects:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{LfoBlock, LowPassFilterBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Rich harmonic source let osc = builder.add(OscillatorBlock::new(110.0, Waveform::Sawtooth, None)); // Resonant low-pass filter let filter = builder.add(LowPassFilterBlock::new(800.0, 4.0)); // LFO to sweep the cutoff let lfo = builder.add(LfoBlock::new(0.5, 3000.0, Waveform::Sine, None)); // Build chain builder.connect(osc, 0, filter, 0); builder.modulate(lfo, filter, "cutoff"); let graph = builder.build(); }
See the 10_filter_modulation example for a working demonstration.
Wobble Bass
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{GainBlock, LfoBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Sub bass oscillator let osc = builder.add(OscillatorBlock::new(55.0, Waveform::Saw, None)); // Slow LFO for amplitude wobble let wobble_lfo = builder.add(LfoBlock::new(2.0, 0.8, Waveform::Sine, None)); // Gain block for wobble effect let gain = builder.add(GainBlock::new(-6.0, None)); builder.connect(osc, 0, gain, 0); builder.modulate(wobble_lfo, gain, "level"); let graph = builder.build(); }
Auto-Pan
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{LfoBlock, OscillatorBlock, PannerBlock}, 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)); // LFO for pan position (slow sweep) let pan_lfo = builder.add(LfoBlock::new(0.25, 1.0, Waveform::Sine, None)); // Panner block let pan = builder.add(PannerBlock::new(0.0)); builder.connect(osc, 0, pan, 0); // Modulate pan position builder.modulate(pan_lfo, pan, "position"); let graph = builder.build(); }
See the 07_stereo_panner example for a multi-layer drone with independent pan modulation.
Next Steps
- Working with Audio Files - Apply modulation to file playback
- MIDI Integration - Trigger envelopes with MIDI
Working with Audio Files
This tutorial covers reading and writing audio files with bbx_audio.
Prerequisites
Add bbx_file to your project:
[dependencies]
bbx_dsp = "0.1"
bbx_file = "0.1"
Prior knowledge: This tutorial assumes familiarity with:
- Your First DSP Graph - GraphBuilder and processing
- Adding Effects - Effect chains (for processing examples)
Supported Formats
| Format | Read | Write |
|---|---|---|
| WAV | Yes | Yes |
Reading WAV Files
Creating a File Reader
use bbx_file::readers::wav::WavFileReader; fn main() -> Result<(), Box<dyn std::error::Error>> { let reader = WavFileReader::from_path("audio/sample.wav")?; // Get file information println!("Sample rate: {}", reader.sample_rate()); println!("Channels: {}", reader.num_channels()); println!("Duration: {} seconds", reader.duration_seconds()); Ok(()) }
Using FileInputBlock
Add a file input to your DSP graph:
use bbx_dsp::{ blocks::{FileInputBlock, GainBlock}, graph::GraphBuilder, }; use bbx_file::readers::wav::WavFileReader; fn main() -> Result<(), Box<dyn std::error::Error>> { let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Create the reader let reader = WavFileReader::from_path("audio/sample.wav")?; // Add to graph let file_input = builder.add(FileInputBlock::new(Box::new(reader))); // Connect to effects let gain = builder.add(GainBlock::new(-6.0, None)); builder.connect(file_input, 0, gain, 0); let graph = builder.build(); Ok(()) }
Writing WAV Files
Creating a File Writer
use bbx_file::writers::wav::WavFileWriter; fn main() -> Result<(), Box<dyn std::error::Error>> { let writer = WavFileWriter::new( "output.wav", 44100, // Sample rate 2, // Channels 16, // Bits per sample )?; Ok(()) }
Using FileOutputBlock
Add a file output to your DSP graph:
use bbx_dsp::{ blocks::{FileOutputBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; use bbx_file::writers::wav::WavFileWriter; fn main() -> Result<(), Box<dyn std::error::Error>> { let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Add oscillator let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Create writer let writer = WavFileWriter::new("output.wav", 44100, 2, 16)?; // Add file output block let file_output = builder.add(FileOutputBlock::new(Box::new(writer))); // Connect oscillator to file output builder.connect(osc, 0, file_output, 0); let mut graph = builder.build(); // Process and write audio let mut left = vec![0.0f32; 512]; let mut right = vec![0.0f32; 512]; // Write 5 seconds of audio let num_buffers = (5.0 * 44100.0 / 512.0) as usize; for _ in 0..num_buffers { let mut outputs: [&mut [f32]; 2] = [&mut left, &mut right]; graph.process_buffers(&mut outputs); } // Finalize the file graph.finalize(); Ok(()) }
Processing Audio Files
Combine file input and output for offline processing:
use bbx_dsp::{ blocks::{FileInputBlock, FileOutputBlock, GainBlock, PannerBlock}, graph::GraphBuilder, }; use bbx_file::{ readers::wav::WavFileReader, writers::wav::WavFileWriter, }; fn main() -> Result<(), Box<dyn std::error::Error>> { // Open input file let reader = WavFileReader::from_path("input.wav")?; let sample_rate = reader.sample_rate(); let num_channels = reader.num_channels(); // Create graph let mut builder = GraphBuilder::<f32>::new(sample_rate, 512, num_channels); // File input let file_in = builder.add(FileInputBlock::new(Box::new(reader))); // Process: add some effects let gain = builder.add(GainBlock::new(-3.0, None)); let pan = builder.add(PannerBlock::new(25.0)); builder.connect(file_in, 0, gain, 0); builder.connect(gain, 0, pan, 0); // File output let writer = WavFileWriter::new("output.wav", sample_rate as u32, num_channels, 16)?; let file_out = builder.add(FileOutputBlock::new(Box::new(writer))); builder.connect(pan, 0, file_out, 0); let mut graph = builder.build(); // Process entire file let mut outputs = vec![vec![0.0f32; 512]; num_channels]; loop { let mut output_refs: Vec<&mut [f32]> = outputs.iter_mut() .map(|v| v.as_mut_slice()) .collect(); graph.process_buffers(&mut output_refs); // Check if file input is exhausted // (implementation depends on your needs) break; // Placeholder } graph.finalize(); Ok(()) }
Error Handling
Handle common file errors:
use bbx_file::readers::wav::WavFileReader; fn main() { match WavFileReader::from_path("nonexistent.wav") { Ok(reader) => { println!("Loaded: {} channels, {} Hz", reader.num_channels(), reader.sample_rate()); } Err(e) => { eprintln!("Failed to load audio file: {}", e); } } }
Performance Tips
- Buffer size: Use larger buffers for file processing (2048+ samples)
- Non-blocking I/O:
FileOutputBlockuses non-blocking I/O internally - Memory: Large files are streamed, not loaded entirely into memory
- Finalization: Always call
finalize()to flush buffers and close files
See the 13_file_processing example for a complete offline processing pipeline.
Next Steps
- MIDI Integration - Control playback with MIDI
- JUCE Integration - Use in a plugin
MIDI Integration
This tutorial covers MIDI message handling with bbx_audio.
Prerequisites
Add bbx_midi to your project:
[dependencies]
bbx_midi = "0.1"
Prior knowledge: For synthesizer integration examples, familiarity with:
- Your First DSP Graph - Graph basics
- Parameter Modulation - Envelope triggering
MIDI Message Types
bbx_midi supports standard MIDI messages:
| Message Type | Description |
|---|---|
| NoteOn | Key pressed |
| NoteOff | Key released |
| ControlChange | CC messages (knobs, sliders) |
| PitchWheel | Pitch bend |
| ProgramChange | Preset selection |
| PolyphonicAftertouch | Per-key pressure |
| ChannelAftertouch | Channel-wide pressure |
Parsing MIDI Messages
#![allow(unused)] fn main() { use bbx_midi::message::{MidiMessage, MidiMessageStatus}; fn handle_midi(data: &[u8]) { if let Some(message) = MidiMessage::from_bytes(data) { match message.status() { MidiMessageStatus::NoteOn => { let note = message.note(); let velocity = message.velocity(); println!("Note On: {} vel {}", note, velocity); } MidiMessageStatus::NoteOff => { let note = message.note(); println!("Note Off: {}", note); } MidiMessageStatus::ControlChange => { let cc = message.controller(); let value = message.value(); println!("CC {}: {}", cc, value); } MidiMessageStatus::PitchWheel => { let bend = message.pitch_bend(); println!("Pitch Bend: {}", bend); } _ => {} } } } }
Lock-Free MIDI Buffer
For thread-safe communication between MIDI and audio threads, use the lock-free MIDI buffer:
use bbx_midi::{midi_buffer, MidiMessage}; fn main() { // Create producer/consumer pair let (mut producer, mut consumer) = midi_buffer(64); // MIDI thread: push messages let msg = MidiMessage::new([0x90, 60, 100]); producer.try_send(msg); // Audio thread: pop messages (realtime-safe) while let Some(msg) = consumer.try_pop() { println!("{:?}", msg); } }
The buffer uses an SPSC ring buffer internally:
MidiBufferProducerfor the MIDI input threadMidiBufferConsumerfor the audio thread (all operations are lock-free)
MIDI to Frequency
Convert MIDI note numbers to frequency:
#![allow(unused)] fn main() { fn midi_to_freq(note: u8) -> f32 { 440.0 * 2.0_f32.powf((note as f32 - 69.0) / 12.0) } // Examples: // midi_to_freq(60) = 261.63 Hz (Middle C) // midi_to_freq(69) = 440.00 Hz (A4) // midi_to_freq(72) = 523.25 Hz (C5) }
Velocity Scaling
Convert velocity to amplitude:
#![allow(unused)] fn main() { fn velocity_to_amplitude(velocity: u8) -> f32 { // Linear scaling velocity as f32 / 127.0 } fn velocity_to_amplitude_curve(velocity: u8) -> f32 { // Logarithmic curve for more natural response let normalized = velocity as f32 / 127.0; normalized * normalized // Square for exponential feel } }
Simple MIDI Synth
Combine MIDI with oscillators. For a complete implementation with audio output, see Building a Terminal Synthesizer - Part 3.
#![allow(unused)] fn main() { use bbx_dsp::{graph::GraphBuilder, waveform::Waveform}; use bbx_midi::message::{MidiMessage, MidiMessageStatus}; struct Voice { note: u8, frequency: f32, velocity: f32, active: bool, } impl Voice { fn new() -> Self { Self { note: 0, frequency: 440.0, velocity: 0.0, active: false, } } fn note_on(&mut self, note: u8, velocity: u8) { self.note = note; self.frequency = 440.0 * 2.0_f32.powf((note as f32 - 69.0) / 12.0); self.velocity = velocity as f32 / 127.0; self.active = true; } fn note_off(&mut self, note: u8) { if self.note == note { self.active = false; } } } fn process_midi(voice: &mut Voice, message: &MidiMessage) { match message.status() { MidiMessageStatus::NoteOn => { if message.velocity() > 0 { voice.note_on(message.note(), message.velocity()); } else { voice.note_off(message.note()); } } MidiMessageStatus::NoteOff => { voice.note_off(message.note()); } _ => {} } } }
Control Change Mapping
Map CC messages to parameters:
#![allow(unused)] fn main() { use bbx_midi::message::{MidiMessage, MidiMessageStatus}; // Standard CC numbers const CC_MOD_WHEEL: u8 = 1; const CC_VOLUME: u8 = 7; const CC_PAN: u8 = 10; const CC_EXPRESSION: u8 = 11; const CC_SUSTAIN: u8 = 64; struct SynthParams { volume: f32, pan: f32, mod_depth: f32, sustain: bool, } fn handle_cc(params: &mut SynthParams, message: &MidiMessage) { if message.status() != MidiMessageStatus::ControlChange { return; } let cc = message.controller(); let value = message.value() as f32 / 127.0; match cc { CC_VOLUME => params.volume = value, CC_PAN => params.pan = (value * 2.0) - 1.0, // -1 to +1 CC_MOD_WHEEL => params.mod_depth = value, CC_SUSTAIN => params.sustain = message.value() >= 64, _ => {} } } }
Real-Time MIDI Input
For real-time MIDI input, use the streaming API:
use bbx_midi::stream::MidiInputStream; fn main() { // Create MIDI input stream // The callback runs on the MIDI thread let stream = MidiInputStream::new(vec![], |message| { println!("Received: {:?}", message); }); // Initialize and start listening let handle = stream.init(); // Keep running... std::thread::sleep(std::time::Duration::from_secs(60)); // Clean up handle.join().unwrap(); }
Next Steps
- Building a Terminal Synthesizer - Terminal synth with MIDI input
- JUCE Integration - Use MIDI in plugins
- DSP Graph Architecture - Understand the system
Real-Time Visualization
This tutorial shows how to visualize audio in real-time using bbx_draw with nannou.
Prerequisites
Add bbx_draw and nannou to your project:
[dependencies]
bbx_draw = "0.1"
bbx_dsp = "0.1"
nannou = "0.19"
Prior knowledge: This tutorial assumes familiarity with:
- Your First DSP Graph - Building and processing graphs
- nannou - Basic nannou application structure
Setting Up the Audio Bridge
The audio bridge connects your audio processing to the visualization thread:
#![allow(unused)] fn main() { use bbx_draw::{audio_bridge, AudioBridgeProducer, AudioBridgeConsumer}; // Create a bridge with capacity for 16 audio frames let (producer, consumer) = audio_bridge(16); }
The producer sends frames from the audio thread, the consumer receives them in the visualization thread.
Your First Waveform Visualizer
Let's create a basic waveform display:
use bbx_draw::{audio_bridge, WaveformVisualizer, Visualizer}; use nannou::prelude::*; struct Model { visualizer: WaveformVisualizer, } fn model(app: &App) -> Model { app.new_window().view(view).build().unwrap(); let (_producer, consumer) = audio_bridge(16); Model { visualizer: WaveformVisualizer::new(consumer), } } fn update(_app: &App, model: &mut Model, _update: Update) { model.visualizer.update(); } fn view(app: &App, model: &Model, frame: Frame) { let draw = app.draw(); draw.background().color(BLACK); model.visualizer.draw(&draw, app.window_rect()); draw.to_frame(app, &frame).unwrap(); } fn main() { nannou::app(model).update(update).run(); }
Sending Audio to the Visualizer
In your audio processing code, send frames to the producer:
#![allow(unused)] fn main() { use bbx_draw::AudioFrame; use bbx_dsp::Frame; fn audio_callback(producer: &mut AudioBridgeProducer, samples: &[f32]) { let frame = Frame::new(samples, 44100, 2); producer.try_send(frame); // Non-blocking } }
try_send() never blocks. If the buffer is full, frames are dropped, which is acceptable for visualization.
Adding a Spectrum Analyzer
Display frequency content alongside the waveform:
#![allow(unused)] fn main() { use bbx_draw::{ audio_bridge, WaveformVisualizer, SpectrumAnalyzer, Visualizer, config::{SpectrumConfig, SpectrumDisplayMode}, }; use nannou::prelude::*; struct Model { waveform: WaveformVisualizer, spectrum: SpectrumAnalyzer, } fn model(app: &App) -> Model { app.new_window().view(view).build().unwrap(); // Share data between visualizers using separate bridges let (_prod1, cons1) = audio_bridge(16); let (_prod2, cons2) = audio_bridge(16); let spectrum_config = SpectrumConfig { display_mode: SpectrumDisplayMode::Filled, ..Default::default() }; Model { waveform: WaveformVisualizer::new(cons1), spectrum: SpectrumAnalyzer::with_config(cons2, spectrum_config), } } fn update(_app: &App, model: &mut Model, _update: Update) { model.waveform.update(); model.spectrum.update(); } fn view(app: &App, model: &Model, frame: Frame) { let draw = app.draw(); draw.background().color(BLACK); let win = app.window_rect(); let (top, bottom) = win.split_top(win.h() * 0.5); model.waveform.draw(&draw, top); model.spectrum.draw(&draw, bottom); draw.to_frame(app, &frame).unwrap(); } }
Visualizing a DSP Graph
Display the structure of your DSP graph:
#![allow(unused)] fn main() { use bbx_draw::{GraphTopologyVisualizer, Visualizer}; use bbx_dsp::{blocks::{GainBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; use nannou::prelude::*; struct Model { visualizer: GraphTopologyVisualizer, } fn model(app: &App) -> Model { app.new_window().view(view).build().unwrap(); 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 topology = builder.capture_topology(); Model { visualizer: GraphTopologyVisualizer::new(topology), } } fn update(_app: &App, model: &mut Model, _update: Update) { model.visualizer.update(); } fn view(app: &App, model: &Model, frame: Frame) { let draw = app.draw(); draw.background().color(BLACK); model.visualizer.draw(&draw, app.window_rect()); draw.to_frame(app, &frame).unwrap(); } }
Complete Example with Audio
Here's a full example with audio generation and visualization:
use bbx_draw::{audio_bridge, AudioBridgeProducer, WaveformVisualizer, Visualizer}; use bbx_dsp::{blocks::OscillatorBlock, graph::GraphBuilder, waveform::Waveform, Frame}; use nannou::prelude::*; use std::sync::{Arc, Mutex}; struct Model { visualizer: WaveformVisualizer, producer: Arc<Mutex<AudioBridgeProducer>>, graph: bbx_dsp::graph::Graph<f32>, } fn model(app: &App) -> Model { app.new_window().view(view).build().unwrap(); let (producer, consumer) = audio_bridge(16); let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let graph = builder.build(); Model { visualizer: WaveformVisualizer::new(consumer), producer: Arc::new(Mutex::new(producer)), graph, } } fn update(_app: &App, model: &mut Model, _update: Update) { // Generate audio let mut left = vec![0.0f32; 512]; let mut right = vec![0.0f32; 512]; let mut outputs: [&mut [f32]; 2] = [&mut left, &mut right]; model.graph.process_buffers(&mut outputs); // Send to visualizer let frame = Frame::new(&left, 44100, 1); if let Ok(mut producer) = model.producer.lock() { producer.try_send(frame); } model.visualizer.update(); } fn view(app: &App, model: &Model, frame: Frame) { let draw = app.draw(); draw.background().color(BLACK); model.visualizer.draw(&draw, app.window_rect()); draw.to_frame(app, &frame).unwrap(); } fn main() { nannou::app(model).update(update).run(); }
Next Steps
- Visualizer Trait - Create custom visualizers
- Audio Bridge - Bridge configuration details
- Sketch Discovery - Manage multiple sketches
- Visualization Threading - Threading model details
Sketch Discovery with Sketchbook
This tutorial covers the sketch discovery and management system in bbx_draw.
Prerequisites
Enable the sketchbook feature (enabled by default):
[dependencies]
bbx_draw = { version = "0.1", features = ["sketchbook"] }
Prior knowledge: Review Real-Time Visualization first to understand the Visualizer trait and audio bridge pattern.
The Sketch Trait
Implement Sketch to make your sketch discoverable:
#![allow(unused)] fn main() { use bbx_draw::sketch::Sketch; use nannou::{App, Frame, event::Update}; pub struct MySketch { time: f32, } impl Sketch for MySketch { fn name(&self) -> &str { "My Sketch" } fn description(&self) -> &str { "A simple animated sketch" } fn model(app: &App) -> Self { app.new_window().build().unwrap(); Self { time: 0.0 } } fn update(&mut self, _app: &App, update: Update) { self.time += update.since_last.as_secs_f32(); } fn view(&self, app: &App, frame: Frame) { let draw = app.draw(); draw.background().color(nannou::color::BLACK); // Draw something... draw.to_frame(app, &frame).unwrap(); } } }
Creating a Sketchbook
use bbx_draw::sketch::Sketchbook; fn main() -> std::io::Result<()> { // Uses platform cache directory let sketchbook = Sketchbook::new()?; Ok(()) }
Default cache locations:
- Linux:
~/.cache/bbx_draw/sketches - macOS:
~/Library/Caches/bbx_draw/sketches - Windows:
%LOCALAPPDATA%\bbx_draw\sketches
Custom Cache Directory
#![allow(unused)] fn main() { use bbx_draw::sketch::Sketchbook; use std::path::PathBuf; let cache_dir = PathBuf::from("./my_sketches"); let sketchbook = Sketchbook::with_cache_dir(cache_dir)?; }
Discovering Sketches
Scan a directory for sketch files:
use bbx_draw::sketch::Sketchbook; use std::path::PathBuf; fn main() -> std::io::Result<()> { let mut sketchbook = Sketchbook::new()?; let sketches_dir = PathBuf::from("./sketches"); let count = sketchbook.discover(&sketches_dir)?; println!("Discovered {} sketches", count); Ok(()) }
The discover method:
- Scans the directory for
.rsfiles - Extracts metadata from doc comments
- Caches results for future use
Managing Sketches
Listing All Sketches
#![allow(unused)] fn main() { for sketch in sketchbook.list() { println!("{}: {}", sketch.name, sketch.description); } }
Finding by Name
#![allow(unused)] fn main() { if let Some(sketch) = sketchbook.get("My Sketch") { println!("Found: {:?}", sketch.source_path); } }
Manual Registration
#![allow(unused)] fn main() { use bbx_draw::sketch::SketchMetadata; use std::time::SystemTime; use std::path::PathBuf; let metadata = SketchMetadata { name: "Custom Sketch".to_string(), description: "Manually registered".to_string(), source_path: PathBuf::from("./custom.rs"), last_modified: SystemTime::now(), }; sketchbook.register(metadata)?; }
Removing Sketches
#![allow(unused)] fn main() { if let Some(removed) = sketchbook.remove("Old Sketch") { println!("Removed: {}", removed.name); } }
Metadata Extraction
The sketchbook extracts descriptions from doc comments (//!):
#![allow(unused)] fn main() { //! A waveform visualizer sketch. //! Shows audio waveforms in real-time. use bbx_draw::*; // ... }
This becomes:
name: "waveform_visualizer" (from filename)
description: "A waveform visualizer sketch. Shows audio waveforms in real-time."
SketchMetadata Fields
| Field | Type | Description |
|---|---|---|
name | String | Display name (from filename) |
description | String | From doc comments |
source_path | PathBuf | Path to source file |
last_modified | SystemTime | File modification time |
Example: Sketch Browser
use bbx_draw::sketch::Sketchbook; use std::path::PathBuf; fn main() -> std::io::Result<()> { let mut sketchbook = Sketchbook::new()?; // Discover sketches in current directory let dir = PathBuf::from("./sketches"); sketchbook.discover(&dir)?; // List available sketches println!("Available sketches:"); for (i, sketch) in sketchbook.list().iter().enumerate() { println!(" {}. {} - {}", i + 1, sketch.name, sketch.description); } // Get user selection let name = "waveform_basic"; if let Some(sketch) = sketchbook.get(name) { println!("\nRunning: {}", sketch.name); println!("Source: {:?}", sketch.source_path); // Launch the sketch... } Ok(()) }
Caching
The sketchbook persists metadata to registry.json in the cache directory:
[
{
"name": "waveform_basic",
"description": "Basic waveform visualizer",
"source_path": "/path/to/waveform_basic.rs",
"last_modified": 1704067200
}
]
Cache is automatically:
- Loaded on
new()orwith_cache_dir() - Updated on
discover(),register(),remove()
Next Steps
- Visualizer Trait - Build visualizers for your sketches
- Real-Time Visualization - Audio visualization tutorial
JUCE Plugin Integration Overview
bbx_audio provides a complete solution for writing audio plugin DSP in Rust while using JUCE for the UI and plugin framework.
Architecture
JUCE AudioProcessor (C++)
|
v
+-----------+
| bbx::Graph| <-- RAII C++ wrapper
+-----------+
|
v (C FFI calls)
+------------+
| bbx_ffi.h | <-- Generated C header
+------------+
|
v
+-------------+
| PluginGraph | <-- Your Rust DSP (implements PluginDsp)
+-------------+
|
v
+------------+
| DSP Blocks | <-- Gain, Panner, Filters, etc.
+------------+
Key Components
Rust Side
PluginDsptrait - Defines the interface your DSP must implementbbx_plugin_ffi!macro - Generates all C FFI exports automatically- Parameter system - Define parameters in JSON or Rust, generate indices for both languages
C++ Side
bbx_ffi.h- C header with FFI function declarations and error codesbbx_graph.h- Header-only C++ RAII wrapper class- Parameter indices - Generated
#defineconstants matching Rust indices
Quick Start Checklist
For experienced developers, here's the minimal setup:
- Add Corrosion submodule:
git submodule add https://github.com/corrosion-rs/corrosion.git vendor/corrosion - Create
dsp/Cargo.tomlwithbbx_plugindependency andcrate-type = ["staticlib"] - Copy
bbx_ffi.handbbx_graph.htodsp/include/ - Implement
PluginDsptrait and callbbx_plugin_ffi!(YourType) - Add Corrosion to CMakeLists.txt and link
dspto your plugin target - Use
bbx::Graphin your AudioProcessor
For detailed guidance, follow the integration steps below.
Integration Steps
- Project Setup - Directory structure and prerequisites
- Rust Crate Configuration - Set up
Cargo.tomland FFI headers - CMake with Corrosion - Configure the build system
- Implementing PluginDsp - Write your DSP processing chain
- Parameter System - Define and manage plugin parameters
- AudioProcessor Integration - Connect to JUCE
- Complete Example - Full working reference
Reference Documentation:
- FFI Integration - C FFI layer details
- C FFI Header Reference - Complete
bbx_ffi.hdocumentation - C++ RAII Wrapper - Using
bbx::Graphin JUCE
Benefits
- Type Safety: Rust's type system prevents memory bugs in DSP code
- Performance: Zero-cost abstractions with no runtime overhead
- Separation: Clean boundary between DSP logic and UI/framework code
- Testability: DSP can be tested independently of the plugin framework
- Portability: Same DSP code works with any C++-compatible framework
Limitations
- Build Complexity: Requires Rust toolchain in addition to C++ build
- Debug Boundaries: Debugging across FFI requires care
- No Hot Reload: DSP changes require full rebuild and plugin reload
Project Setup
This guide walks through setting up a JUCE plugin project with Rust DSP.
Directory Structure
A typical project structure:
my-plugin/
├── CMakeLists.txt
├── dsp/ # Rust DSP crate
│ ├── Cargo.toml
│ ├── include/
│ │ ├── bbx_ffi.h # C FFI header
│ │ └── bbx_graph.h # C++ RAII wrapper
│ └── src/
│ └── lib.rs # PluginDsp implementation
├── src/ # JUCE plugin source
│ ├── PluginProcessor.cpp
│ ├── PluginProcessor.h
│ ├── PluginEditor.cpp
│ └── PluginEditor.h
└── vendor/
└── corrosion/ # Git submodule
Prerequisites
- Rust toolchain - Install from rustup.rs
- CMake 3.15+ - For building the plugin
- JUCE - Framework for the plugin
- Corrosion - CMake integration for Rust
Adding Corrosion
Add Corrosion as a git submodule:
git submodule add https://github.com/corrosion-rs/corrosion.git vendor/corrosion
Next Steps
- Rust Crate Configuration - Set up
Cargo.toml - CMake with Corrosion - Configure the build system
Rust Crate Configuration
Configure your Rust crate for FFI integration with JUCE.
Cargo.toml
Create a dsp/Cargo.toml:
[package]
name = "dsp"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["staticlib", "cdylib"]
[dependencies]
bbx_plugin = "0.1"
Crate Types
staticlib- Static library for linking into C++ (recommended)cdylib- Dynamic library (alternative approach)
Using staticlib is recommended as it bundles all Rust code into a single library that links cleanly with the plugin.
Using Git Dependencies
For the latest development version:
[dependencies]
bbx_plugin = { git = "https://github.com/blackboxaudio/bbx_audio" }
FFI Headers
Copy these headers to dsp/include/:
bbx_ffi.h- C FFI function declarationsbbx_graph.h- C++ RAII wrapper class
Get them from the bbx_plugin include directory.
For detailed header documentation, see FFI Integration.
lib.rs Structure
Your dsp/src/lib.rs should:
- Import the necessary types
- Define your DSP struct
- Implement
PluginDsp - Call the FFI macro
#![allow(unused)] fn main() { use bbx_plugin::{PluginDsp, DspContext, bbx_plugin_ffi}; pub struct PluginGraph { // Your DSP blocks } impl Default for PluginGraph { fn default() -> Self { Self::new() } } impl PluginDsp for PluginGraph { fn new() -> Self { PluginGraph { // Initialize blocks } } fn prepare(&mut self, context: &DspContext) { // Called when audio specs change } fn reset(&mut self) { // Clear DSP state } fn apply_parameters(&mut self, params: &[f32]) { // Map parameter values to blocks } fn process( &mut self, inputs: &[&[f32]], outputs: &mut [&mut [f32]], context: &DspContext, ) { // Process audio } } // Generate FFI exports bbx_plugin_ffi!(PluginGraph); }
CMake with Corrosion
Configure CMake to build and link your Rust DSP crate.
CMakeLists.txt
Add these sections to your plugin's CMakeLists.txt:
# Add Corrosion for Rust integration
add_subdirectory(vendor/corrosion)
# Import the Rust crate
corrosion_import_crate(MANIFEST_PATH dsp/Cargo.toml)
# Your plugin target (created by JUCE's juce_add_plugin)
# Replace ${PLUGIN_TARGET} with your actual target name
# Include the FFI headers
target_include_directories(${PLUGIN_TARGET} PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/dsp/include)
# Link the Rust library
target_link_libraries(${PLUGIN_TARGET} PRIVATE dsp)
Full Example
A complete CMakeLists.txt example:
cmake_minimum_required(VERSION 3.15)
project(MyPlugin VERSION 1.0.0)
# Add JUCE (adjust path as needed)
add_subdirectory(JUCE)
# Add Corrosion
add_subdirectory(vendor/corrosion)
# Import Rust crate
corrosion_import_crate(MANIFEST_PATH dsp/Cargo.toml)
# Define the plugin
juce_add_plugin(MyPlugin
PLUGIN_MANUFACTURER_CODE Mfr1
PLUGIN_CODE Plg1
FORMATS AU VST3 Standalone
PRODUCT_NAME "My Plugin")
# Add JUCE modules
target_link_libraries(MyPlugin PRIVATE
juce::juce_audio_processors
juce::juce_audio_utils)
# Include FFI headers
target_include_directories(MyPlugin PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/dsp/include)
# Link Rust library
target_link_libraries(MyPlugin PRIVATE dsp)
Platform-Specific Notes
macOS
No additional configuration needed. Corrosion handles universal binary creation.
Windows
Ensure Rust is in your PATH. You may need to specify the MSVC toolchain:
rustup default stable-msvc
Linux
Install required development packages:
sudo apt install alsa libasound2-dev
Build Commands
# Configure
cmake -B build -DCMAKE_BUILD_TYPE=Release
# Build
cmake --build build --config Release
Verify the Build
After building, verify the Rust library was created:
# macOS/Linux - check for the static library
ls build/cargo/build/*/release/libdsp.a
# Windows - check for the static library
dir build\cargo\build\*\release\dsp.lib
If the library is missing, check the build/cargo/ directory for Rust build errors. Common issues:
- Missing Rust toolchain (run
rustup show) - Cargo.toml syntax errors
- Missing
staticlibcrate type
Troubleshooting
"Cannot find -ldsp"
Ensure Corrosion successfully built the Rust crate. Check build/cargo/ for build artifacts.
Linking Errors
Verify the crate type in Cargo.toml is set to staticlib:
[lib]
crate-type = ["staticlib"]
Header Not Found
Check that target_include_directories points to the correct path containing bbx_ffi.h.
Implementing PluginDsp
The PluginDsp trait defines the interface between your Rust DSP code and the FFI layer.
The PluginDsp Trait
#![allow(unused)] fn main() { pub trait PluginDsp: Default + Send + 'static { fn new() -> Self; fn prepare(&mut self, context: &DspContext); fn reset(&mut self); fn apply_parameters(&mut self, params: &[f32]); fn process(&mut self, inputs: &[&[f32]], outputs: &mut [&mut [f32]], midi_events: &[MidiEvent], context: &DspContext); // Optional MIDI callbacks (default no-ops, suitable for effects) fn note_on(&mut self, note: u8, velocity: u8, sample_offset: u32) {} fn note_off(&mut self, note: u8, sample_offset: u32) {} fn control_change(&mut self, cc: u8, value: u8, sample_offset: u32) {} fn pitch_bend(&mut self, value: i16, sample_offset: u32) {} } }
Trait Bounds
Default- Required for the FFI layer to create instancesSend- Allows transfer between threads (audio thread)'static- No borrowed references (owned data only)
Method Reference
new()
Create a new instance with default configuration.
#![allow(unused)] fn main() { fn new() -> Self { Self { gain: GainBlock::new(0.0, None), panner: PannerBlock::new(0.0), } } }
prepare()
Called when audio specifications change. Initialize blocks with the new context.
#![allow(unused)] fn main() { fn prepare(&mut self, context: &DspContext) { // context.sample_rate - Sample rate in Hz // context.buffer_size - Samples per buffer // context.num_channels - Number of channels self.gain.prepare(context); self.panner.prepare(context); } }
reset()
Clear all DSP state (filter histories, delay lines, oscillator phases).
#![allow(unused)] fn main() { fn reset(&mut self) { self.gain.reset(); self.panner.reset(); } }
apply_parameters()
Map the flat parameter array to your DSP blocks.
#![allow(unused)] fn main() { fn apply_parameters(&mut self, params: &[f32]) { // Use generated constants for indices self.gain.level_db = params[PARAM_GAIN]; self.panner.position = params[PARAM_PAN]; } }
process()
Process audio through your DSP chain with MIDI events.
#![allow(unused)] fn main() { fn process( &mut self, inputs: &[&[f32]], outputs: &mut [&mut [f32]], midi_events: &[MidiEvent], context: &DspContext, ) { // inputs[channel][sample] - Input audio // outputs[channel][sample] - Output audio (write here) // midi_events - MIDI events sorted by sample_offset // Handle MIDI (for synthesizers) for event in midi_events { match event.message.get_status() { MidiMessageStatus::NoteOn => { let note = event.message.get_note().unwrap(); let vel = event.message.get_velocity().unwrap(); self.note_on(note, vel, event.sample_offset); } MidiMessageStatus::NoteOff => { let note = event.message.get_note().unwrap(); self.note_off(note, event.sample_offset); } _ => {} } } // Process audio for ch in 0..context.num_channels { for i in 0..context.buffer_size { outputs[ch][i] = inputs[ch][i] * self.gain.multiplier(); } } } }
Complete Example
#![allow(unused)] fn main() { use bbx_plugin::{PluginDsp, DspContext, bbx_plugin_ffi}; use bbx_plugin::blocks::{GainBlock, PannerBlock, DcBlockerBlock}; use bbx_midi::MidiEvent; // Parameter indices (generated or manual) const PARAM_GAIN: usize = 0; const PARAM_PAN: usize = 1; const PARAM_DC_BLOCK: usize = 2; pub struct PluginGraph { gain: GainBlock<f32>, panner: PannerBlock<f32>, dc_blocker: DcBlockerBlock<f32>, dc_block_enabled: bool, } impl Default for PluginGraph { fn default() -> Self { Self::new() } } impl PluginDsp for PluginGraph { fn new() -> Self { Self { gain: GainBlock::new(0.0, None), panner: PannerBlock::new(0.0), dc_blocker: DcBlockerBlock::new(), dc_block_enabled: false, } } fn prepare(&mut self, context: &DspContext) { self.dc_blocker.prepare(context); } fn reset(&mut self) { self.dc_blocker.reset(); } fn apply_parameters(&mut self, params: &[f32]) { self.gain.level_db = params[PARAM_GAIN]; self.panner.position = params[PARAM_PAN]; self.dc_block_enabled = params[PARAM_DC_BLOCK] > 0.5; } fn process( &mut self, inputs: &[&[f32]], outputs: &mut [&mut [f32]], _midi_events: &[MidiEvent], context: &DspContext, ) { let num_channels = context.num_channels.min(inputs.len()).min(outputs.len()); let num_samples = context.buffer_size; for ch in 0..num_channels { for i in 0..num_samples { let mut sample = inputs[ch][i]; // Apply gain sample *= self.gain.multiplier(); // Apply DC blocker if enabled if self.dc_block_enabled { sample = self.dc_blocker.process_sample(sample, ch); } outputs[ch][i] = sample; } } // Apply panning (modifies stereo field) self.panner.process_stereo(outputs, num_samples); } } // Generate FFI exports bbx_plugin_ffi!(PluginGraph); }
Parameter System
bbx_audio provides two approaches for defining plugin parameters, both capable of generating code for Rust and C++.
Overview
Parameters are passed from C++ to Rust as a flat float array. You need a consistent way to map array indices to parameter meanings on both sides of the FFI boundary.
C++ (JUCE) Rust (bbx_plugin)
----------- ------------------
params[0] = gainValue --> params[PARAM_GAIN]
params[1] = panValue --> params[PARAM_PAN]
Parameter ID vs Index
Note that parameter mapping is by array index, not by name:
- Rust uses
SCREAMING_SNAKE_CASEconstants (PARAM_GAIN = 0) - JUCE uses lowercase string IDs (
"gain") forAudioProcessorValueTreeState
The string IDs are for JUCE's parameter system. The array indices are what crosses the FFI boundary. As long as you load JUCE parameters into the array in the same order as your Rust constants, they will match.
Two Approaches
1. JSON-Based (Recommended)
Define parameters in a parameters.json file. This is ideal when:
- Your DAW/framework also reads parameter definitions
- You want a single source of truth
- Parameters are configured at build time
See parameters.json Format for details.
2. Programmatic
Define parameters as Rust const arrays. This is ideal when:
- Parameters are known at compile time
- You want maximum compile-time verification
- JSON parsing is unnecessary
See Programmatic Definition for details.
Code Generation
Both approaches can generate:
- Rust constants:
pub const PARAM_GAIN: usize = 0; - C header defines:
#define PARAM_GAIN 0
See Code Generation for integration details.
Parameter Types
Both approaches support these parameter types:
| Type | Description | Value Range |
|---|---|---|
boolean | On/off toggle | 0.0 = off, 1.0 = on |
float | Continuous value | min to max |
choice | Discrete options | 0.0, 1.0, 2.0, ... |
Accessing Parameters in Rust
In your apply_parameters() method:
#![allow(unused)] fn main() { fn apply_parameters(&mut self, params: &[f32]) { // Boolean: compare to 0.5 self.mono_enabled = params[PARAM_MONO] > 0.5; // Float: use directly self.gain.level_db = params[PARAM_GAIN]; // Choice: convert to integer let mode = params[PARAM_MODE] as usize; self.routing_mode = match mode { 0 => RoutingMode::Stereo, 1 => RoutingMode::Left, 2 => RoutingMode::Right, _ => RoutingMode::Stereo, }; } }
Passing Parameters from JUCE
In your processBlock():
// Gather current parameter values
m_paramBuffer[PARAM_GAIN] = *gainParam;
m_paramBuffer[PARAM_PAN] = *panParam;
m_paramBuffer[PARAM_MONO] = *monoParam ? 1.0f : 0.0f;
// Pass to Rust DSP
m_rustDsp.Process(
inputs, outputs,
numChannels, numSamples,
m_paramBuffer.data(),
static_cast<uint32_t>(m_paramBuffer.size()));
parameters.json Format
Define plugin parameters in a JSON file for cross-language code generation.
File Structure
{
"parameters": [
{
"id": "GAIN",
"name": "Gain",
"type": "float",
"min": -60.0,
"max": 30.0,
"defaultValue": 0.0,
"unit": "dB"
},
{
"id": "PAN",
"name": "Pan",
"type": "float",
"min": -100.0,
"max": 100.0,
"defaultValue": 0.0
},
{
"id": "MONO",
"name": "Mono",
"type": "boolean",
"defaultValue": false
},
{
"id": "MODE",
"name": "Routing Mode",
"type": "choice",
"choices": ["Stereo", "Left", "Right", "Swap"],
"defaultValueIndex": 0
}
]
}
Field Reference
Required Fields
| Field | Type | Description |
|---|---|---|
id | string | Parameter identifier (uppercase, used in code generation) |
name | string | Display name for UI |
type | string | "boolean", "float", or "choice" |
Boolean Parameters
| Field | Type | Description |
|---|---|---|
defaultValue | boolean | Default state (true or false) |
Float Parameters
| Field | Type | Description |
|---|---|---|
min | number | Minimum value |
max | number | Maximum value |
defaultValue | number | Default value |
unit | string | Optional unit label (e.g., "dB", "Hz", "%") |
midpoint | number | Optional midpoint for skewed ranges |
interval | number | Optional step interval |
fractionDigits | integer | Optional decimal places to display |
Choice Parameters
| Field | Type | Description |
|---|---|---|
choices | string[] | Array of option labels |
defaultValueIndex | integer | Index of default choice (0-based) |
Complete Example
{
"parameters": [
{
"id": "INVERT_LEFT",
"name": "Invert Left",
"type": "boolean",
"defaultValue": false
},
{
"id": "INVERT_RIGHT",
"name": "Invert Right",
"type": "boolean",
"defaultValue": false
},
{
"id": "CHANNEL_MODE",
"name": "Channel Mode",
"type": "choice",
"choices": ["Stereo", "Left", "Right", "Swap"],
"defaultValueIndex": 0
},
{
"id": "MONO",
"name": "Sum to Mono",
"type": "boolean",
"defaultValue": false
},
{
"id": "GAIN",
"name": "Gain",
"type": "float",
"min": -60.0,
"max": 30.0,
"defaultValue": 0.0,
"unit": "dB",
"fractionDigits": 1
},
{
"id": "PAN",
"name": "Pan",
"type": "float",
"min": -100.0,
"max": 100.0,
"defaultValue": 0.0,
"interval": 1.0
},
{
"id": "DC_OFFSET",
"name": "DC Offset Removal",
"type": "boolean",
"defaultValue": false
}
]
}
Parsing in Rust
#![allow(unused)] fn main() { use bbx_plugin::ParamsFile; fn load_parameters() -> ParamsFile { let json = include_str!("../parameters.json"); ParamsFile::from_json(json).expect("Failed to parse parameters.json") } }
Generated Output
From the above JSON, code generation produces:
Rust:
#![allow(unused)] fn main() { pub const PARAM_INVERT_LEFT: usize = 0; pub const PARAM_INVERT_RIGHT: usize = 1; pub const PARAM_CHANNEL_MODE: usize = 2; pub const PARAM_MONO: usize = 3; pub const PARAM_GAIN: usize = 4; pub const PARAM_PAN: usize = 5; pub const PARAM_DC_OFFSET: usize = 6; pub const PARAM_COUNT: usize = 7; }
C Header:
#define PARAM_INVERT_LEFT 0
#define PARAM_INVERT_RIGHT 1
#define PARAM_CHANNEL_MODE 2
#define PARAM_MONO 3
#define PARAM_GAIN 4
#define PARAM_PAN 5
#define PARAM_DC_OFFSET 6
#define PARAM_COUNT 7
Programmatic Parameter Definition
Define parameters as Rust const arrays for compile-time verification.
Defining Parameters
Use ParamDef constructors to define parameters:
#![allow(unused)] fn main() { use bbx_plugin::{ParamDef, ParamType}; const PARAMETERS: &[ParamDef] = &[ ParamDef::float("GAIN", "Gain", -60.0, 30.0, 0.0), ParamDef::float("PAN", "Pan", -100.0, 100.0, 0.0), ParamDef::bool("MONO", "Mono", false), ParamDef::choice("MODE", "Mode", &["Stereo", "Left", "Right"], 0), ]; }
ParamDef Constructors
Boolean
#![allow(unused)] fn main() { ParamDef::bool(id, name, default) }
id- Parameter identifier (e.g.,"MONO")name- Display name (e.g.,"Mono")default- Default value (trueorfalse)
Float
#![allow(unused)] fn main() { ParamDef::float(id, name, min, max, default) }
id- Parameter identifiername- Display namemin- Minimum valuemax- Maximum valuedefault- Default value
Choice
#![allow(unused)] fn main() { ParamDef::choice(id, name, choices, default_index) }
id- Parameter identifiername- Display namechoices- Static slice of option labelsdefault_index- Index of default choice
ParamType Enum
For more control, use ParamType directly:
#![allow(unused)] fn main() { use bbx_plugin::{ParamDef, ParamType}; const CUSTOM_PARAM: ParamDef = ParamDef { id: "FREQ", name: "Frequency", param_type: ParamType::Float { min: 20.0, max: 20000.0, default: 440.0, }, }; }
Generating Code
Generate Rust constants:
#![allow(unused)] fn main() { use bbx_plugin::generate_rust_indices_from_defs; let rust_code = generate_rust_indices_from_defs(PARAMETERS); // Output: // pub const PARAM_GAIN: usize = 0; // pub const PARAM_PAN: usize = 1; // pub const PARAM_MONO: usize = 2; // pub const PARAM_MODE: usize = 3; // pub const PARAM_COUNT: usize = 4; }
Generate C header:
#![allow(unused)] fn main() { use bbx_plugin::generate_c_header_from_defs; let c_header = generate_c_header_from_defs(PARAMETERS); // Output: // #define PARAM_GAIN 0 // #define PARAM_PAN 1 // #define PARAM_MONO 2 // #define PARAM_MODE 3 // #define PARAM_COUNT 4 }
Manual Constants
For simple cases, you can define constants manually:
#![allow(unused)] fn main() { // Manual parameter indices pub const PARAM_GAIN: usize = 0; pub const PARAM_PAN: usize = 1; pub const PARAM_MONO: usize = 2; pub const PARAM_COUNT: usize = 3; }
And corresponding C header:
#define PARAM_GAIN 0
#define PARAM_PAN 1
#define PARAM_MONO 2
#define PARAM_COUNT 3
This approach is simpler but requires manual synchronization between Rust and C++.
When to Use Programmatic Definition
- Parameters are fixed at compile time
- No need for JSON parsing overhead
- Maximum type safety and compile-time checks
- Simple plugins with few parameters
Parameter Code Generation
Generate consistent parameter indices for Rust and C++ from a single source.
Build Script Integration
The recommended approach is to generate code at build time using a build.rs script.
Using parameters.json
// build.rs use std::env; use std::fs; use std::path::Path; fn main() { let out_dir = env::var("OUT_DIR").unwrap(); // Read parameters.json let json = fs::read_to_string("parameters.json") .expect("Failed to read parameters.json"); let params: serde_json::Value = serde_json::from_str(&json) .expect("Failed to parse parameters.json"); // Generate Rust constants let mut rust_code = String::from("// Auto-generated - DO NOT EDIT\n\n"); if let Some(parameters) = params["parameters"].as_array() { for (index, param) in parameters.iter().enumerate() { let id = param["id"].as_str().unwrap(); rust_code.push_str(&format!("pub const PARAM_{}: usize = {};\n", id, index)); } rust_code.push_str(&format!("\npub const PARAM_COUNT: usize = {};\n", parameters.len())); } // Write to OUT_DIR let dest_path = Path::new(&out_dir).join("params.rs"); fs::write(&dest_path, rust_code).unwrap(); // Generate C header (prefer using ParamsFile API instead - see below) let mut c_header = String::from("/* Auto-generated - DO NOT EDIT */\n\n"); c_header.push_str("#ifndef BBX_PARAMS_H\n#define BBX_PARAMS_H\n\n"); if let Some(parameters) = params["parameters"].as_array() { for (index, param) in parameters.iter().enumerate() { let id = param["id"].as_str().unwrap(); c_header.push_str(&format!("#define PARAM_{} {}\n", id, index)); } c_header.push_str(&format!("\n#define PARAM_COUNT {}\n\n", parameters.len())); // Generate PARAM_IDS array for dynamic iteration c_header.push_str("static const char* PARAM_IDS[PARAM_COUNT] = {\n"); for (i, param) in parameters.iter().enumerate() { let id = param["id"].as_str().unwrap(); let comma = if i < parameters.len() - 1 { "," } else { "" }; c_header.push_str(&format!(" \"{}\"{}\n", id, comma)); } c_header.push_str("};\n"); } c_header.push_str("\n#endif /* BBX_PARAMS_H */\n"); // Write to include directory fs::write("include/bbx_params.h", c_header).unwrap(); println!("cargo:rerun-if-changed=parameters.json"); }
Including Generated Code
In your lib.rs:
#![allow(unused)] fn main() { // Include the generated parameter constants include!(concat!(env!("OUT_DIR"), "/params.rs")); }
Using ParamsFile API
Alternatively, use the built-in API:
// build.rs use bbx_plugin::ParamsFile; use std::fs; fn main() { let json = fs::read_to_string("parameters.json").unwrap(); let params = ParamsFile::from_json(&json).unwrap(); // Generate Rust code let rust_code = params.generate_rust_indices(); fs::write(format!("{}/params.rs", std::env::var("OUT_DIR").unwrap()), rust_code).unwrap(); // Generate C header let c_header = params.generate_c_header(); fs::write("include/bbx_params.h", c_header).unwrap(); println!("cargo:rerun-if-changed=parameters.json"); }
Using Programmatic Definitions
For compile-time definitions:
// build.rs use bbx_plugin::{ParamDef, generate_rust_indices_from_defs, generate_c_header_from_defs}; use std::fs; const PARAMETERS: &[ParamDef] = &[ ParamDef::float("GAIN", "Gain", -60.0, 30.0, 0.0), ParamDef::bool("MONO", "Mono", false), ]; fn main() { let rust_code = generate_rust_indices_from_defs(PARAMETERS); fs::write(format!("{}/params.rs", std::env::var("OUT_DIR").unwrap()), rust_code).unwrap(); let c_header = generate_c_header_from_defs(PARAMETERS); fs::write("include/bbx_params.h", c_header).unwrap(); }
CMake Integration
Include the generated header in CMake:
# Ensure Rust build runs first (Corrosion handles this)
corrosion_import_crate(MANIFEST_PATH dsp/Cargo.toml)
# Include the generated header directory
target_include_directories(${PLUGIN_TARGET} PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/dsp/include)
Using PARAM_IDS in C++
The generated header includes a PARAM_IDS array for dynamic parameter iteration:
// Generated header contains:
// static const char* PARAM_IDS[PARAM_COUNT] = { "GAIN", "MONO", ... };
Caching Parameter Pointers
Cache atomic pointers once in the constructor:
// In processor.h
std::array<std::atomic<float>*, PARAM_COUNT> m_paramPointers {};
// In processor.cpp constructor
for (size_t i = 0; i < PARAM_COUNT; ++i) {
m_paramPointers[i] = m_parameters.getRawParameterValue(juce::String(PARAM_IDS[i]));
}
Loading Parameters in processBlock
Replace manual per-parameter loading with a loop:
// Instead of:
// auto* gain = m_parameters.getRawParameterValue("GAIN");
// m_paramBuffer[PARAM_GAIN] = gain ? gain->load() : 0.0f;
// ... repeated for each parameter
// Use:
for (size_t i = 0; i < PARAM_COUNT; ++i) {
m_paramBuffer[i] = m_paramPointers[i] ? m_paramPointers[i]->load() : 0.0f;
}
This eliminates manual C++ updates when adding parameters.
Verification
Add a test to verify Rust and C++ constants match:
#![allow(unused)] fn main() { #[test] fn test_param_indices_match() { // If this compiles, indices are in sync assert_eq!(PARAM_COUNT, 7); assert!(PARAM_GAIN < PARAM_COUNT); assert!(PARAM_PAN < PARAM_COUNT); } }
FFI Integration
The FFI (Foreign Function Interface) layer bridges Rust DSP code with C++ JUCE code.
Overview
bbx_audio uses a C FFI for maximum compatibility:
Rust C FFI C++
----- ----- ---
PluginDsp impl --> bbx_ffi.h --> bbx::Graph wrapper
Key Components
Rust Side
bbx_plugin_ffi!macro - Generates all FFI exportsBbxGraphhandle - Opaque pointer type for CBbxErrorenum - Error codes for FFI returns
C/C++ Side
bbx_ffi.h- C header with function declarationsbbx_graph.h- C++ RAII wrapper class
Generated Functions
The bbx_plugin_ffi! macro generates these extern "C" functions:
| Function | Description |
|---|---|
bbx_graph_create() | Allocate and return a new DSP handle |
bbx_graph_destroy() | Free the DSP handle and its resources |
bbx_graph_prepare() | Initialize for given sample rate/buffer size |
bbx_graph_reset() | Clear all DSP state |
bbx_graph_process() | Process a block of audio |
Error Handling
FFI functions return BbxError codes:
typedef enum BbxError {
BBX_ERROR_OK = 0,
BBX_ERROR_NULL_POINTER = 1,
BBX_ERROR_INVALID_PARAMETER = 2,
BBX_ERROR_INVALID_BUFFER_SIZE = 3,
BBX_ERROR_GRAPH_NOT_PREPARED = 4,
BBX_ERROR_ALLOCATION_FAILED = 5,
} BbxError;
Check return values in C++:
BbxError err = bbx_graph_prepare(handle, sampleRate, bufferSize, numChannels);
if (err != BBX_ERROR_OK) {
// Handle error
}
Memory Management
- Allocation:
bbx_graph_create()allocates Rust memory - Ownership: The handle owns the Rust struct
- Deallocation:
bbx_graph_destroy()frees all Rust memory - Null Safety: Functions are safe to call with NULL handles
Thread Safety
- The DSP handle is
Send(can be transferred between threads) - Audio processing is single-threaded (call from audio thread only)
- Do not share handles between threads without synchronization
See Also
- C FFI Header Reference - Complete
bbx_ffi.hdocumentation - C++ RAII Wrapper - Using
bbx::Graphin JUCE
C FFI Header Reference
Complete reference for bbx_ffi.h.
Header Overview
#ifndef BBX_FFI_H
#define BBX_FFI_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
// Types and functions...
#ifdef __cplusplus
}
#endif
#endif /* BBX_FFI_H */
Types
BbxError
Error codes for FFI operations:
typedef enum BbxError {
BBX_ERROR_OK = 0,
BBX_ERROR_NULL_POINTER = 1,
BBX_ERROR_INVALID_PARAMETER = 2,
BBX_ERROR_INVALID_BUFFER_SIZE = 3,
BBX_ERROR_GRAPH_NOT_PREPARED = 4,
BBX_ERROR_ALLOCATION_FAILED = 5,
} BbxError;
| Error | Value | Description |
|---|---|---|
BBX_ERROR_OK | 0 | Operation succeeded |
BBX_ERROR_NULL_POINTER | 1 | Handle was NULL |
BBX_ERROR_INVALID_PARAMETER | 2 | Invalid parameter value |
BBX_ERROR_INVALID_BUFFER_SIZE | 3 | Buffer size was 0 |
BBX_ERROR_GRAPH_NOT_PREPARED | 4 | Graph not prepared before processing |
BBX_ERROR_ALLOCATION_FAILED | 5 | Memory allocation failed |
BbxGraph
Opaque handle to the Rust DSP:
typedef struct BbxGraph BbxGraph;
Never dereference or inspect this pointer - it's an opaque handle.
BbxMidiStatus
MIDI message status types:
typedef enum BbxMidiStatus {
BBX_MIDI_STATUS_UNKNOWN = 0,
BBX_MIDI_STATUS_NOTE_OFF = 1,
BBX_MIDI_STATUS_NOTE_ON = 2,
BBX_MIDI_STATUS_POLYPHONIC_AFTERTOUCH = 3,
BBX_MIDI_STATUS_CONTROL_CHANGE = 4,
BBX_MIDI_STATUS_PROGRAM_CHANGE = 5,
BBX_MIDI_STATUS_CHANNEL_AFTERTOUCH = 6,
BBX_MIDI_STATUS_PITCH_WHEEL = 7,
} BbxMidiStatus;
BbxMidiMessage
MIDI message structure (matches Rust MidiMessage with repr(C)):
typedef struct BbxMidiMessage {
uint8_t channel; // MIDI channel (0-15)
BbxMidiStatus status; // Message type
uint8_t data_1; // First data byte (e.g., note number)
uint8_t data_2; // Second data byte (e.g., velocity)
} BbxMidiMessage;
BbxMidiEvent
MIDI event with sample-accurate timing:
typedef struct BbxMidiEvent {
BbxMidiMessage message; // The MIDI message
uint32_t sample_offset; // Sample offset within the buffer
} BbxMidiEvent;
The sample_offset allows sample-accurate MIDI timing within a buffer.
Functions
bbx_graph_create
BbxGraph* bbx_graph_create(void);
Create a new DSP effects chain.
Returns: Handle to the effects chain, or NULL if allocation fails.
Usage:
BbxGraph* handle = bbx_graph_create();
if (handle == NULL) {
// Allocation failed
}
bbx_graph_destroy
void bbx_graph_destroy(BbxGraph* handle);
Destroy a DSP effects chain and free all resources.
Parameters:
handle- Effects chain handle (safe to call withNULL)
Usage:
bbx_graph_destroy(handle);
handle = NULL; // Avoid dangling pointer
bbx_graph_prepare
BbxError bbx_graph_prepare(
BbxGraph* handle,
double sample_rate,
uint32_t buffer_size,
uint32_t num_channels
);
Prepare the effects chain for playback.
Parameters:
handle- Effects chain handlesample_rate- Sample rate in Hz (e.g., 44100.0, 48000.0)buffer_size- Number of samples per buffernum_channels- Number of audio channels
Returns: BBX_ERROR_OK on success, or an error code.
Usage:
BbxError err = bbx_graph_prepare(handle, 44100.0, 512, 2);
if (err != BBX_ERROR_OK) {
// Handle error
}
bbx_graph_reset
BbxError bbx_graph_reset(BbxGraph* handle);
Reset the effects chain state (clear filters, delay lines, etc.).
Parameters:
handle- Effects chain handle
Returns: BBX_ERROR_OK on success.
Usage:
bbx_graph_reset(handle);
bbx_graph_process
void bbx_graph_process(
BbxGraph* handle,
const float* const* inputs,
float* const* outputs,
uint32_t num_channels,
uint32_t num_samples,
const float* params,
uint32_t num_params,
const BbxMidiEvent* midi_events,
uint32_t num_midi_events
);
Process a block of audio with optional MIDI events.
Parameters:
handle- Effects chain handleinputs- Array of input channel pointersoutputs- Array of output channel pointersnum_channels- Number of audio channelsnum_samples- Number of samples per channelparams- Pointer to flat array of parameter valuesnum_params- Number of parametersmidi_events- Pointer to array of MIDI events (may beNULLfor effects)num_midi_events- Number of MIDI events in the array
Usage:
float params[PARAM_COUNT] = { gainValue, panValue, ... };
// For effects (no MIDI):
bbx_graph_process(
handle,
inputPtrs,
outputPtrs,
numChannels,
numSamples,
params,
PARAM_COUNT,
NULL,
0
);
// For synthesizers (with MIDI):
BbxMidiEvent midiEvents[16];
uint32_t numMidiEvents = convertJuceMidi(midiBuffer, midiEvents, 16);
bbx_graph_process(
handle,
inputPtrs,
outputPtrs,
numChannels,
numSamples,
params,
PARAM_COUNT,
midiEvents,
numMidiEvents
);
Note: Parameter index constants (PARAM_*) are defined in the generated bbx_params.h header, not in bbx_ffi.h. See Parameter Code Generation for details.
C++ RAII Wrapper
bbx_graph.h provides a header-only C++ wrapper for safe resource management.
Overview
The bbx::Graph class wraps the C FFI with RAII semantics:
- Constructor calls
bbx_graph_create() - Destructor calls
bbx_graph_destroy() - Move semantics prevent accidental copies
Usage
Include the header:
#include <bbx_graph.h>
Create and use a graph:
bbx::Graph dsp; // Automatically creates handle
if (dsp.IsValid()) {
dsp.Prepare(sampleRate, bufferSize, numChannels);
dsp.Process(inputs, outputs, numChannels, numSamples, params, numParams);
}
Class Reference
Constructor
Graph();
Creates a new DSP graph. Check IsValid() to verify allocation succeeded.
Destructor
~Graph();
Destroys the graph and frees all resources.
Move Operations
Graph(Graph&& other) noexcept;
Graph& operator=(Graph&& other) noexcept;
The class is movable but not copyable:
bbx::Graph a;
bbx::Graph b = std::move(a); // OK - a is now invalid
// bbx::Graph c = b; // Error - not copyable
Prepare
BbxError Prepare(double sampleRate, uint32_t bufferSize, uint32_t numChannels);
Prepare for playback. Call from prepareToPlay().
Reset
BbxError Reset();
Reset DSP state. Call from releaseResources().
Process
void Process(
const float* const* inputs,
float* const* outputs,
uint32_t numChannels,
uint32_t numSamples,
const float* params,
uint32_t numParams,
const BbxMidiEvent* midiEvents = nullptr,
uint32_t numMidiEvents = 0
);
Process audio with optional MIDI events. Call from processBlock().
For effects (no MIDI):
dsp.Process(inputs, outputs, numChannels, numSamples, params, numParams);
For synthesizers (with MIDI):
dsp.Process(inputs, outputs, numChannels, numSamples, params, numParams, midiEvents, numMidiEvents);
IsValid
bool IsValid() const;
Returns true if the handle is valid.
handle
BbxGraph* handle();
const BbxGraph* handle() const;
Access the raw handle for advanced use.
Complete Header
#pragma once
#include "bbx_ffi.h"
namespace bbx {
class Graph {
public:
Graph()
: m_handle(bbx_graph_create())
{
}
~Graph()
{
if (m_handle) {
bbx_graph_destroy(m_handle);
}
}
// Non-copyable
Graph(const Graph&) = delete;
Graph& operator=(const Graph&) = delete;
// Movable
Graph(Graph&& other) noexcept
: m_handle(other.m_handle)
{
other.m_handle = nullptr;
}
Graph& operator=(Graph&& other) noexcept
{
if (this != &other) {
if (m_handle) {
bbx_graph_destroy(m_handle);
}
m_handle = other.m_handle;
other.m_handle = nullptr;
}
return *this;
}
BbxError Prepare(double sampleRate, uint32_t bufferSize, uint32_t numChannels)
{
if (!m_handle) {
return BBX_ERROR_NULL_POINTER;
}
return bbx_graph_prepare(m_handle, sampleRate, bufferSize, numChannels);
}
BbxError Reset()
{
if (!m_handle) {
return BBX_ERROR_NULL_POINTER;
}
return bbx_graph_reset(m_handle);
}
void Process(const float* const* inputs,
float* const* outputs,
uint32_t numChannels,
uint32_t numSamples,
const float* params,
uint32_t numParams,
const BbxMidiEvent* midiEvents = nullptr,
uint32_t numMidiEvents = 0)
{
if (m_handle) {
bbx_graph_process(m_handle, inputs, outputs, numChannels, numSamples,
params, numParams, midiEvents, numMidiEvents);
}
}
bool IsValid() const { return m_handle != nullptr; }
BbxGraph* handle() { return m_handle; }
const BbxGraph* handle() const { return m_handle; }
private:
BbxGraph* m_handle { nullptr };
};
} // namespace bbx
AudioProcessor Integration
Integrate bbx::Graph with your JUCE AudioProcessor.
Overview
The integration pattern:
- Store
bbx::Graphas a member - Call
Prepare()inprepareToPlay() - Call
Reset()inreleaseResources() - Call
Process()inprocessBlock()
Processor Header
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#include <bbx_graph.h>
#include <bbx_params.h>
#include <array>
#include <atomic>
#include <vector>
class PluginAudioProcessor : public juce::AudioProcessor {
public:
PluginAudioProcessor();
~PluginAudioProcessor() override;
void prepareToPlay(double sampleRate, int samplesPerBlock) override;
void releaseResources() override;
void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
// ... other AudioProcessor methods
private:
juce::AudioProcessorValueTreeState m_parameters;
bbx::Graph m_rustDsp;
std::vector<float> m_paramBuffer;
std::array<std::atomic<float>*, PARAM_COUNT> m_paramPointers {};
// Pointer arrays for FFI
static constexpr size_t MAX_CHANNELS = 8;
std::array<const float*, MAX_CHANNELS> m_inputPtrs {};
std::array<float*, MAX_CHANNELS> m_outputPtrs {};
};
Implementation
Constructor
PluginAudioProcessor::PluginAudioProcessor()
: AudioProcessor(/* bus layout */)
, m_parameters(*this, nullptr, "Parameters", createParameterLayout())
{
// Allocate parameter buffer
m_paramBuffer.resize(PARAM_COUNT);
// Cache parameter pointers for efficient access in processBlock
for (size_t i = 0; i < PARAM_COUNT; ++i) {
m_paramPointers[i] = m_parameters.getRawParameterValue(juce::String(PARAM_IDS[i]));
}
}
prepareToPlay
void PluginAudioProcessor::prepareToPlay(double sampleRate, int samplesPerBlock)
{
BbxError err = m_rustDsp.Prepare(
sampleRate,
static_cast<uint32_t>(samplesPerBlock),
static_cast<uint32_t>(getTotalNumOutputChannels())
);
if (err != BBX_ERROR_OK) {
DBG("Failed to prepare Rust DSP: " + juce::String(static_cast<int>(err)));
}
}
releaseResources
void PluginAudioProcessor::releaseResources()
{
m_rustDsp.Reset();
}
processBlock
void PluginAudioProcessor::processBlock(juce::AudioBuffer<float>& buffer,
juce::MidiBuffer& midiMessages)
{
juce::ScopedNoDenormals noDenormals;
auto numChannels = static_cast<uint32_t>(buffer.getNumChannels());
auto numSamples = static_cast<uint32_t>(buffer.getNumSamples());
// Clamp to max supported channels
numChannels = std::min(numChannels, static_cast<uint32_t>(MAX_CHANNELS));
// Load parameter values from cached atomic pointers
for (size_t i = 0; i < PARAM_COUNT; ++i) {
m_paramBuffer[i] = m_paramPointers[i] ? m_paramPointers[i]->load() : 0.0f;
}
// Build pointer arrays
for (uint32_t ch = 0; ch < numChannels; ++ch) {
m_inputPtrs[ch] = buffer.getReadPointer(static_cast<int>(ch));
m_outputPtrs[ch] = buffer.getWritePointer(static_cast<int>(ch));
}
// Process through Rust DSP
m_rustDsp.Process(
m_inputPtrs.data(),
m_outputPtrs.data(),
numChannels,
numSamples,
m_paramBuffer.data(),
static_cast<uint32_t>(m_paramBuffer.size())
);
}
Parameter Integration
The recommended approach uses PARAM_IDS from the generated header for dynamic iteration:
// In constructor - cache all parameter pointers
for (size_t i = 0; i < PARAM_COUNT; ++i) {
m_paramPointers[i] = m_parameters.getRawParameterValue(juce::String(PARAM_IDS[i]));
}
// In processBlock - load all values dynamically
for (size_t i = 0; i < PARAM_COUNT; ++i) {
m_paramBuffer[i] = m_paramPointers[i] ? m_paramPointers[i]->load() : 0.0f;
}
This eliminates per-parameter boilerplate. When adding new parameters, only update parameters.json and the Rust apply_parameters() method.
Parameter Layout
Create the layout from JSON using cortex::ParameterManager or manually:
juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout()
{
// Option 1: Load from embedded JSON (recommended)
juce::String json = juce::String::createStringFromData(
PluginData::parameters_json, PluginData::parameters_jsonSize);
auto params = cortex::ParameterManager::LoadParametersFromJson(json);
return cortex::ParameterManager::CreateParameterLayout(params);
// Option 2: Manual definition
std::vector<std::unique_ptr<juce::RangedAudioParameter>> params;
params.push_back(std::make_unique<juce::AudioParameterFloat>(
"GAIN", "Gain",
juce::NormalisableRange<float>(-60.0f, 30.0f, 0.1f),
0.0f));
// ... more parameters
return { params.begin(), params.end() };
}
Thread Safety Notes
processBlock()runs on the audio thread- Parameter reads should use atomics
- Never allocate memory in
processBlock() - The
bbx::Graphis already designed for audio thread use
MIDI Integration
For synthesizers that need MIDI input, convert JUCE's MidiBuffer to BbxMidiEvent array:
// In processor header
static constexpr size_t MAX_MIDI_EVENTS = 128;
std::array<BbxMidiEvent, MAX_MIDI_EVENTS> m_midiEvents {};
// Helper function to convert JUCE MidiBuffer to BbxMidiEvent array
uint32_t convertMidiBuffer(const juce::MidiBuffer& buffer,
BbxMidiEvent* events,
uint32_t maxEvents)
{
uint32_t count = 0;
for (const auto metadata : buffer) {
if (count >= maxEvents) break;
const auto msg = metadata.getMessage();
auto& event = events[count];
event.sample_offset = static_cast<uint32_t>(metadata.samplePosition);
event.message.channel = static_cast<uint8_t>(msg.getChannel() - 1);
if (msg.isNoteOn()) {
event.message.status = BBX_MIDI_STATUS_NOTE_ON;
event.message.data_1 = static_cast<uint8_t>(msg.getNoteNumber());
event.message.data_2 = static_cast<uint8_t>(msg.getVelocity());
} else if (msg.isNoteOff()) {
event.message.status = BBX_MIDI_STATUS_NOTE_OFF;
event.message.data_1 = static_cast<uint8_t>(msg.getNoteNumber());
event.message.data_2 = 0;
} else if (msg.isController()) {
event.message.status = BBX_MIDI_STATUS_CONTROL_CHANGE;
event.message.data_1 = static_cast<uint8_t>(msg.getControllerNumber());
event.message.data_2 = static_cast<uint8_t>(msg.getControllerValue());
} else if (msg.isPitchWheel()) {
event.message.status = BBX_MIDI_STATUS_PITCH_WHEEL;
int pitchValue = msg.getPitchWheelValue() - 8192;
event.message.data_1 = static_cast<uint8_t>(pitchValue & 0x7F);
event.message.data_2 = static_cast<uint8_t>((pitchValue >> 7) & 0x7F);
} else {
continue;
}
count++;
}
return count;
}
Use in processBlock():
void PluginAudioProcessor::processBlock(juce::AudioBuffer<float>& buffer,
juce::MidiBuffer& midiMessages)
{
// ... parameter loading ...
// Convert MIDI for synths
uint32_t numMidiEvents = convertMidiBuffer(midiMessages, m_midiEvents.data(), MAX_MIDI_EVENTS);
// Process with MIDI
m_rustDsp.Process(
m_inputPtrs.data(),
m_outputPtrs.data(),
numChannels,
numSamples,
m_paramBuffer.data(),
static_cast<uint32_t>(m_paramBuffer.size()),
m_midiEvents.data(),
numMidiEvents);
}
For effect plugins that don't need MIDI, pass nullptr and 0 for the MIDI parameters.
Complete Example Walkthrough
A complete example of integrating Rust DSP with a JUCE plugin.
Project Structure
my-utility-plugin/
├── CMakeLists.txt
├── dsp/
│ ├── Cargo.toml
│ ├── parameters.json
│ ├── include/
│ │ ├── bbx_ffi.h
│ │ └── bbx_graph.h
│ └── src/
│ └── lib.rs
├── src/
│ ├── PluginProcessor.cpp
│ ├── PluginProcessor.h
│ ├── PluginEditor.cpp
│ └── PluginEditor.h
└── vendor/
└── corrosion/
Step 1: Define Parameters
dsp/parameters.json:
{
"parameters": [
{
"id": "GAIN",
"name": "Gain",
"type": "float",
"min": -60.0,
"max": 30.0,
"defaultValue": 0.0,
"unit": "dB"
},
{
"id": "PAN",
"name": "Pan",
"type": "float",
"min": -100.0,
"max": 100.0,
"defaultValue": 0.0
},
{
"id": "MONO",
"name": "Mono",
"type": "boolean",
"defaultValue": false
}
]
}
Step 2: Rust DSP Implementation
dsp/Cargo.toml:
[package]
name = "dsp"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["staticlib"]
[dependencies]
bbx_plugin = "0.1"
dsp/src/lib.rs:
#![allow(unused)] fn main() { use bbx_plugin::{ PluginDsp, DspContext, bbx_plugin_ffi, blocks::{GainBlock, PannerBlock}, }; // Parameter indices const PARAM_GAIN: usize = 0; const PARAM_PAN: usize = 1; const PARAM_MONO: usize = 2; const PARAM_COUNT: usize = 3; pub struct PluginGraph { gain: GainBlock<f32>, panner: PannerBlock<f32>, mono: bool, } impl Default for PluginGraph { fn default() -> Self { Self::new() } } impl PluginDsp for PluginGraph { fn new() -> Self { Self { gain: GainBlock::new(0.0, None), panner: PannerBlock::new(0.0), mono: false, } } fn prepare(&mut self, _context: &DspContext) { // Nothing to prepare for these simple blocks } fn reset(&mut self) { // Nothing to reset } fn apply_parameters(&mut self, params: &[f32]) { self.gain.level_db = params[PARAM_GAIN]; self.panner.position = params[PARAM_PAN]; self.mono = params[PARAM_MONO] > 0.5; } fn process( &mut self, inputs: &[&[f32]], outputs: &mut [&mut [f32]], context: &DspContext, ) { let num_samples = context.buffer_size; let multiplier = self.gain.multiplier(); // Apply gain for ch in 0..inputs.len().min(outputs.len()) { for i in 0..num_samples { outputs[ch][i] = inputs[ch][i] * multiplier; } } // Apply mono summing if enabled if self.mono && outputs.len() >= 2 { for i in 0..num_samples { let sum = (outputs[0][i] + outputs[1][i]) * 0.5; outputs[0][i] = sum; outputs[1][i] = sum; } } // Apply panning if outputs.len() >= 2 { let (left_gain, right_gain) = self.panner.gains(); for i in 0..num_samples { outputs[0][i] *= left_gain; outputs[1][i] *= right_gain; } } } } bbx_plugin_ffi!(PluginGraph); }
Step 3: CMake Configuration
CMakeLists.txt:
cmake_minimum_required(VERSION 3.15)
project(MyUtilityPlugin VERSION 1.0.0)
add_subdirectory(JUCE)
add_subdirectory(vendor/corrosion)
corrosion_import_crate(MANIFEST_PATH dsp/Cargo.toml)
juce_add_plugin(MyUtilityPlugin
PLUGIN_MANUFACTURER_CODE Bbxa
PLUGIN_CODE Util
FORMATS AU VST3 Standalone
PRODUCT_NAME "My Utility Plugin")
target_sources(MyUtilityPlugin PRIVATE
src/PluginProcessor.cpp
src/PluginEditor.cpp)
target_link_libraries(MyUtilityPlugin PRIVATE
juce::juce_audio_processors
juce::juce_audio_utils
dsp)
target_include_directories(MyUtilityPlugin PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/dsp/include)
Step 4: JUCE Processor
src/PluginProcessor.h:
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#include <bbx_graph.h>
#include <bbx_ffi.h>
#include <array>
#include <vector>
class PluginAudioProcessor : public juce::AudioProcessor {
public:
PluginAudioProcessor();
~PluginAudioProcessor() override = default;
void prepareToPlay(double sampleRate, int samplesPerBlock) override;
void releaseResources() override;
void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
juce::AudioProcessorEditor* createEditor() override;
bool hasEditor() const override { return true; }
const juce::String getName() const override { return "My Utility Plugin"; }
bool acceptsMidi() const override { return false; }
bool producesMidi() const override { return false; }
double getTailLengthSeconds() const override { return 0.0; }
int getNumPrograms() override { return 1; }
int getCurrentProgram() override { return 0; }
void setCurrentProgram(int) override {}
const juce::String getProgramName(int) override { return {}; }
void changeProgramName(int, const juce::String&) override {}
void getStateInformation(juce::MemoryBlock& destData) override;
void setStateInformation(const void* data, int sizeInBytes) override;
juce::AudioProcessorValueTreeState parameters;
private:
juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout();
bbx::Graph m_rustDsp;
std::vector<float> m_paramBuffer;
std::atomic<float>* gainParam = nullptr;
std::atomic<float>* panParam = nullptr;
std::atomic<float>* monoParam = nullptr;
static constexpr size_t MAX_CHANNELS = 8;
std::array<const float*, MAX_CHANNELS> m_inputPtrs {};
std::array<float*, MAX_CHANNELS> m_outputPtrs {};
};
src/PluginProcessor.cpp:
#include "PluginProcessor.h"
#include "PluginEditor.h"
PluginAudioProcessor::PluginAudioProcessor()
: AudioProcessor(BusesProperties()
.withInput("Input", juce::AudioChannelSet::stereo(), true)
.withOutput("Output", juce::AudioChannelSet::stereo(), true))
, parameters(*this, nullptr, "Parameters", createParameterLayout())
{
gainParam = parameters.getRawParameterValue("gain");
panParam = parameters.getRawParameterValue("pan");
monoParam = parameters.getRawParameterValue("mono");
m_paramBuffer.resize(PARAM_COUNT);
}
juce::AudioProcessorValueTreeState::ParameterLayout
PluginAudioProcessor::createParameterLayout()
{
std::vector<std::unique_ptr<juce::RangedAudioParameter>> params;
params.push_back(std::make_unique<juce::AudioParameterFloat>(
"gain", "Gain",
juce::NormalisableRange<float>(-60.0f, 30.0f, 0.1f),
0.0f));
params.push_back(std::make_unique<juce::AudioParameterFloat>(
"pan", "Pan",
juce::NormalisableRange<float>(-100.0f, 100.0f, 1.0f),
0.0f));
params.push_back(std::make_unique<juce::AudioParameterBool>(
"mono", "Mono", false));
return { params.begin(), params.end() };
}
void PluginAudioProcessor::prepareToPlay(double sampleRate, int samplesPerBlock)
{
m_rustDsp.Prepare(
sampleRate,
static_cast<uint32_t>(samplesPerBlock),
static_cast<uint32_t>(getTotalNumOutputChannels()));
}
void PluginAudioProcessor::releaseResources()
{
m_rustDsp.Reset();
}
void PluginAudioProcessor::processBlock(juce::AudioBuffer<float>& buffer,
juce::MidiBuffer&)
{
juce::ScopedNoDenormals noDenormals;
auto numChannels = static_cast<uint32_t>(buffer.getNumChannels());
auto numSamples = static_cast<uint32_t>(buffer.getNumSamples());
numChannels = std::min(numChannels, static_cast<uint32_t>(MAX_CHANNELS));
m_paramBuffer[PARAM_GAIN] = gainParam->load();
m_paramBuffer[PARAM_PAN] = panParam->load();
m_paramBuffer[PARAM_MONO] = monoParam->load();
for (uint32_t ch = 0; ch < numChannels; ++ch) {
m_inputPtrs[ch] = buffer.getReadPointer(static_cast<int>(ch));
m_outputPtrs[ch] = buffer.getWritePointer(static_cast<int>(ch));
}
m_rustDsp.Process(
m_inputPtrs.data(),
m_outputPtrs.data(),
numChannels,
numSamples,
m_paramBuffer.data(),
static_cast<uint32_t>(m_paramBuffer.size()));
}
juce::AudioProcessorEditor* PluginAudioProcessor::createEditor()
{
return new PluginEditor(*this);
}
void PluginAudioProcessor::getStateInformation(juce::MemoryBlock& destData)
{
auto state = parameters.copyState();
std::unique_ptr<juce::XmlElement> xml(state.createXml());
copyXmlToBinary(*xml, destData);
}
void PluginAudioProcessor::setStateInformation(const void* data, int sizeInBytes)
{
std::unique_ptr<juce::XmlElement> xmlState(getXmlFromBinary(data, sizeInBytes));
if (xmlState && xmlState->hasTagName(parameters.state.getType()))
parameters.replaceState(juce::ValueTree::fromXml(*xmlState));
}
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter()
{
return new PluginAudioProcessor();
}
Step 5: Build
# Configure
cmake -B build -DCMAKE_BUILD_TYPE=Release
# Build
cmake --build build --config Release
The plugin will be in build/MyUtilityPlugin_artefacts/.
bbx_core
Foundational utilities and data structures for the bbx_audio workspace.
Overview
bbx_core provides low-level utilities designed for real-time audio applications:
- Denormal handling to prevent CPU slowdowns
- Lock-free data structures for inter-thread communication
- Stack-allocated containers to avoid heap allocations
- Fast random number generation
Installation
[dependencies]
bbx_core = "0.1"
Features
| Feature | Description |
|---|---|
| Sample | Generic sample type trait with SIMD support |
| SIMD | Vectorized DSP operations (feature-gated) |
| Denormal Handling | Flush denormal floats to zero |
| SPSC Ring Buffer | Lock-free producer-consumer queue |
| Stack Vector | Fixed-capacity heap-free vector |
| Random | Fast XorShift RNG |
| Error Types | Unified error handling |
Quick Example
#![allow(unused)] fn main() { use bbx_core::{StackVec, flush_denormal_f32}; // Stack-allocated buffer for audio processing let mut samples: StackVec<f32, 256> = StackVec::new(); // Process samples, flushing denormals for sample in &mut samples { *sample = flush_denormal_f32(*sample); } }
Design Principles
No Heap Allocations
All data structures are designed to work without heap allocation during audio processing. Buffers are pre-allocated and reused.
Lock-Free
The SPSC ring buffer uses atomic operations instead of mutexes, ensuring consistent performance.
Minimal Dependencies
bbx_core has minimal external dependencies, keeping compile times low and reducing potential issues.
Sample Trait
The Sample trait abstracts over audio sample types (f32, f64), allowing DSP blocks and graphs to be generic over sample precision.
Note: The
Sampletrait is defined inbbx_coreand re-exported bybbx_dspfor convenience.
Trait Definition
#![allow(unused)] fn main() { pub trait Sample: Debug + Copy + Clone + Send + Sync + Add<Output = Self> + Sub<Output = Self> + Mul<Output = Self> + Div<Output = Self> + Neg<Output = Self> + AddAssign + SubAssign + MulAssign + DivAssign + PartialOrd + PartialEq + 'static { /// Zero value (silence) const ZERO: Self; /// One value (full scale) const ONE: Self; /// Machine epsilon const EPSILON: Self; /// Mathematical constants for DSP const PI: Self; // π const INV_PI: Self; // 1/π const FRAC_PI_2: Self; // π/2 const FRAC_PI_3: Self; // π/3 const FRAC_PI_4: Self; // π/4 const TAU: Self; // 2π (full circle) const INV_TAU: Self; // 1/(2π) const PHI: Self; // Golden ratio const E: Self; // Euler's number const SQRT_2: Self; // √2 const INV_SQRT_2: Self; // 1/√2 /// Convert from f64 fn from_f64(value: f64) -> Self; /// Convert to f64 fn to_f64(self) -> f64; } }
Implementations
The trait is implemented for f32 and f64:
#![allow(unused)] fn main() { impl Sample for f32 { const ZERO: Self = 0.0; const ONE: Self = 1.0; const EPSILON: Self = f32::EPSILON; const PI: Self = std::f32::consts::PI; const TAU: Self = std::f32::consts::TAU; // ... other constants with f32 precision fn from_f64(value: f64) -> Self { value as f32 } fn to_f64(self) -> f64 { self as f64 } } impl Sample for f64 { const ZERO: Self = 0.0; const ONE: Self = 1.0; const EPSILON: Self = f64::EPSILON; const PI: Self = std::f64::consts::PI; const TAU: Self = std::f64::consts::TAU; // ... other constants with f64 precision fn from_f64(value: f64) -> Self { value } fn to_f64(self) -> f64 { self } } }
Mathematical Constants
The Sample trait provides mathematical constants commonly used in DSP:
| Constant | Value | Common DSP Use |
|---|---|---|
PI | π ≈ 3.14159 | Phase calculations, filter coefficients |
TAU | 2π ≈ 6.28318 | Full cycle/phase wrap, angular frequency |
INV_TAU | 1/(2π) | Frequency-to-phase conversion |
FRAC_PI_2 | π/2 | Quarter-wave, phase shifts |
SQRT_2 | √2 ≈ 1.414 | RMS calculations, equal-power panning |
INV_SQRT_2 | 1/√2 ≈ 0.707 | Equal-power crossfade, normalization |
E | e ≈ 2.718 | Exponential decay, RC filter time constants |
PHI | φ ≈ 1.618 | Golden ratio for aesthetic frequency ratios |
These constants are provided at compile-time precision for both f32 and f64, avoiding runtime conversions in hot paths.
Usage
Generic DSP Code
Write DSP code that works with any sample type:
#![allow(unused)] fn main() { use bbx_core::sample::Sample; fn apply_gain<S: Sample>(samples: &mut [S], gain_db: f64) { let linear = S::from_f64((10.0_f64).powf(gain_db / 20.0)); for sample in samples { *sample = *sample * linear; } } // Works with both f32 and f64 let mut samples_f32: Vec<f32> = vec![0.5, 0.3, 0.1]; let mut samples_f64: Vec<f64> = vec![0.5, 0.3, 0.1]; apply_gain(&mut samples_f32, -6.0); apply_gain(&mut samples_f64, -6.0); }
Using Constants
#![allow(unused)] fn main() { use bbx_core::sample::Sample; fn normalize<S: Sample>(samples: &mut [S]) { let max = samples.iter() .map(|s| if *s < S::ZERO { S::ZERO - *s } else { *s }) .fold(S::ZERO, |a, b| if a > b { a } else { b }); if max > S::ZERO { for sample in samples { *sample = *sample / max; } } } }
SIMD Support
When the simd feature is enabled, the Sample trait provides additional associated types and methods for vectorized processing.
Enabling SIMD
[dependencies]
bbx_core = { version = "...", features = ["simd"] }
SIMD Associated Type and Methods
With the simd feature, the trait includes:
#![allow(unused)] fn main() { pub trait Sample { // ... base methods ... /// The SIMD vector type (f32x4 for f32, f64x4 for f64) #[cfg(feature = "simd")] type Simd: SimdFloat<Scalar = Self> + StdFloat + SimdPartialOrd + Copy + ...; /// Create a SIMD vector with all lanes set to the given value #[cfg(feature = "simd")] fn simd_splat(value: Self) -> Self::Simd; /// Load a SIMD vector from a slice (must have at least 4 elements) #[cfg(feature = "simd")] fn simd_from_slice(slice: &[Self]) -> Self::Simd; /// Convert a SIMD vector to an array #[cfg(feature = "simd")] fn simd_to_array(simd: Self::Simd) -> [Self; 4]; /// Select elements where a > b #[cfg(feature = "simd")] fn simd_select_gt(a: Self::Simd, b: Self::Simd, if_true: Self::Simd, if_false: Self::Simd) -> Self::Simd; /// Select elements where a < b #[cfg(feature = "simd")] fn simd_select_lt(a: Self::Simd, b: Self::Simd, if_true: Self::Simd, if_false: Self::Simd) -> Self::Simd; /// Returns lane offsets [0.0, 1.0, 2.0, 3.0] for phase calculations #[cfg(feature = "simd")] fn simd_lane_offsets() -> Self::Simd; } }
Generic SIMD Example
Write SIMD-accelerated code that works for both f32 and f64:
#![allow(unused)] fn main() { use bbx_core::sample::{Sample, SIMD_LANES}; #[cfg(feature = "simd")] fn apply_gain_simd<S: Sample>(output: &mut [S], gain: S) { let gain_vec = S::simd_splat(gain); let (chunks, remainder) = output.as_chunks_mut::<SIMD_LANES>(); for chunk in chunks { let samples = S::simd_from_slice(chunk); let result = samples * gain_vec; chunk.copy_from_slice(&S::simd_to_array(result)); } for sample in remainder { *sample = *sample * gain; } } }
Choosing a Sample Type
f32 (Recommended)
- Smaller memory footprint (4 bytes vs 8 bytes)
- Better SIMD throughput
- Sufficient precision for most audio work
- Standard for most audio APIs
f64
- Higher precision for:
- Long delay lines
- Accumulating filters
- Scientific/measurement applications
- Common in offline processing
Denormal Handling
Utilities for flushing denormal (subnormal) floating-point values to zero.
Why Denormals Matter
Denormal numbers are very small floating-point values near zero. Processing them can cause significant CPU slowdowns (10-100x slower) on many processors.
In audio processing, denormals commonly occur:
- In filter feedback paths as signals decay
- After gain reduction to very low levels
- In reverb/delay tail-off
API
flush_denormal_f32
Flush a 32-bit float to zero if denormal:
#![allow(unused)] fn main() { use bbx_core::flush_denormal_f32; let small_value = 1.0e-40_f32; // Denormal let result = flush_denormal_f32(small_value); assert_eq!(result, 0.0); let normal_value = 0.5_f32; let result = flush_denormal_f32(normal_value); assert_eq!(result, 0.5); }
flush_denormal_f64
Flush a 64-bit float to zero if denormal:
#![allow(unused)] fn main() { use bbx_core::flush_denormal_f64; let small_value = 1.0e-310_f64; // Denormal let result = flush_denormal_f64(small_value); assert_eq!(result, 0.0); }
Usage Patterns
In Feedback Loops
#![allow(unused)] fn main() { use bbx_core::flush_denormal_f32; fn process_filter(input: f32, state: &mut f32, coefficient: f32) -> f32 { let output = input + *state * coefficient; *state = flush_denormal_f32(output); // Prevent denormal accumulation output } }
Block Processing
#![allow(unused)] fn main() { use bbx_core::flush_denormal_f32; fn process_block(samples: &mut [f32]) { for sample in samples { *sample = flush_denormal_f32(*sample); } } }
Performance
The flush functions use bit manipulation, not branching:
#![allow(unused)] fn main() { // Conceptually (actual implementation may differ): fn flush_denormal_f32(x: f32) -> f32 { let bits = x.to_bits(); let exponent = (bits >> 23) & 0xFF; if exponent == 0 && (bits & 0x007FFFFF) != 0 { 0.0 // Denormal } else { x // Normal } } }
This is typically faster than relying on FPU denormal handling.
Hardware FTZ/DAZ Mode
For maximum performance, bbx_core provides the ftz-daz Cargo feature that enables hardware-level denormal handling on x86/x86_64 processors.
Enabling the Feature
[dependencies]
bbx_core = { version = "...", features = ["ftz-daz"] }
enable_ftz_daz
When the feature is enabled, call enable_ftz_daz() once at the start of each audio processing thread:
#![allow(unused)] fn main() { use bbx_core::denormal::enable_ftz_daz; fn audio_thread_init() { enable_ftz_daz(); // All subsequent float operations on this thread will auto-flush denormals } }
This sets two CPU flags:
- FTZ (Flush-To-Zero): Denormal results are flushed to zero
- DAZ (Denormals-Are-Zero): Denormal inputs are treated as zero
Platform Support
| Platform | Behavior |
|---|---|
| x86/x86_64 | Full FTZ + DAZ via MXCSR register |
| AArch64 (ARM64/Apple Silicon) | FTZ only via FPCR register |
| Other | No-op (use software flush functions) |
bbx_plugin Integration
When using bbx_plugin with the ftz-daz feature enabled, enable_ftz_daz() is called automatically during prepare():
[dependencies]
bbx_plugin = { version = "...", features = ["ftz-daz"] }
This is the recommended approach for audio plugins.
Software vs Hardware Approach
| Approach | Use Case |
|---|---|
flush_denormal_* functions | Cross-platform, targeted flushing in specific code paths |
enable_ftz_daz() | Maximum performance on x86/x86_64, affects all operations |
For production audio plugins on desktop platforms, enabling the ftz-daz feature is recommended. The software flush functions remain useful for cross-platform code or when you need fine-grained control over which values are flushed.
SPSC Ring Buffer
A lock-free single-producer single-consumer ring buffer for inter-thread communication.
Overview
The SPSC (Single-Producer Single-Consumer) ring buffer enables safe communication between two threads without locks. This is ideal for audio applications where:
- The audio thread produces samples
- The UI thread consumes visualization data
- Or vice versa
API
Creating a Buffer
#![allow(unused)] fn main() { use bbx_core::SpscRingBuffer; // Create a buffer with capacity for 1024 samples let (producer, consumer) = SpscRingBuffer::new::<f32>(1024); }
The returned producer and consumer can be sent to different threads.
Producer Operations
#![allow(unused)] fn main() { // Try to push a single item (non-blocking) if producer.try_push(sample).is_ok() { // Sample was added } else { // Buffer was full } // Push multiple items let samples = [0.1, 0.2, 0.3]; let pushed = producer.try_push_slice(&samples); // pushed = number of items actually added }
Consumer Operations
#![allow(unused)] fn main() { // Try to pop a single item (non-blocking) if let Some(sample) = consumer.try_pop() { // Process sample } else { // Buffer was empty } // Pop multiple items into a buffer let mut output = [0.0f32; 256]; let popped = consumer.try_pop_slice(&mut output); // popped = number of items actually read }
Checking Capacity
#![allow(unused)] fn main() { let available = consumer.len(); // Items ready to read let space = producer.capacity() - producer.len(); // Space for writing }
Usage Patterns
Audio Thread to UI Thread
use bbx_core::SpscRingBuffer; use std::thread; fn main() { let (producer, consumer) = SpscRingBuffer::new::<f32>(4096); // Audio thread let audio_handle = thread::spawn(move || { loop { let sample = generate_audio(); let _ = producer.try_push(sample); } }); // UI thread loop { while let Some(sample) = consumer.try_pop() { update_waveform_display(sample); } } }
MIDI Message Queue
#![allow(unused)] fn main() { use bbx_core::SpscRingBuffer; use bbx_midi::MidiMessage; let (midi_producer, midi_consumer) = SpscRingBuffer::new::<MidiMessage>(256); // MIDI input callback fn on_midi_message(msg: MidiMessage) { let _ = midi_producer.try_push(msg); } // Audio thread fn process_audio() { while let Some(msg) = midi_consumer.try_pop() { handle_midi(msg); } } }
Implementation Details
Memory Ordering
The buffer uses atomic operations with appropriate memory ordering:
Relaxedfor capacity checksAcquire/Releasefor actual data transfer- Ensures proper synchronization across threads
Cache Efficiency
The producer and consumer indices are padded to avoid false sharing:
#![allow(unused)] fn main() { #[repr(align(64))] // Cache line alignment struct Producer<T> { write_index: AtomicUsize, // ... } }
Wait-Free
Both push and pop operations are wait-free - they complete in bounded time regardless of what the other thread is doing.
Limitations
- Single producer only: Multiple producers require external synchronization
- Single consumer only: Multiple consumers require external synchronization
- Fixed capacity: Size is set at creation, cannot grow
- Power of two: Capacity is rounded up to nearest power of two
For multiple producers or consumers, use a different data structure or add external synchronization.
Stack Vector
A fixed-capacity vector that stores elements on the stack, avoiding heap allocations.
Overview
StackVec is a vector-like container with:
- Fixed maximum capacity (compile-time constant)
- No heap allocations
- Safe push/pop operations
- Panic-free overflow handling
API
Creating a StackVec
#![allow(unused)] fn main() { use bbx_core::StackVec; // Create an empty stack vector with capacity for 8 f32s let mut vec: StackVec<f32, 8> = StackVec::new(); // Elements must be added via push let _ = vec.push(1.0); let _ = vec.push(2.0); let _ = vec.push(3.0); }
Adding Elements
#![allow(unused)] fn main() { use bbx_core::StackVec; let mut vec: StackVec<f32, 4> = StackVec::new(); // Push returns Ok if there's space, Err(value) if full assert!(vec.push(1.0).is_ok()); assert!(vec.push(2.0).is_ok()); // For performance-critical code, use push_unchecked // (panics in debug mode if full, silently ignores in release) vec.push_unchecked(3.0); }
Removing Elements
#![allow(unused)] fn main() { use bbx_core::StackVec; let mut vec: StackVec<f32, 4> = StackVec::new(); let _ = vec.push(1.0); let _ = vec.push(2.0); let _ = vec.push(3.0); // Pop from the end assert_eq!(vec.pop(), Some(3.0)); assert_eq!(vec.pop(), Some(2.0)); // Clear all elements vec.clear(); assert!(vec.is_empty()); }
Accessing Elements
#![allow(unused)] fn main() { use bbx_core::StackVec; let mut vec: StackVec<f32, 4> = StackVec::new(); let _ = vec.push(1.0); let _ = vec.push(2.0); let _ = vec.push(3.0); // Index access assert_eq!(vec[0], 1.0); // Safe access with get if let Some(value) = vec.get(1) { println!("Second element: {}", value); } // Mutable access vec[0] = 10.0; // Slice access let slice: &[f32] = vec.as_slice(); let mut_slice: &mut [f32] = vec.as_mut_slice(); }
Iteration
#![allow(unused)] fn main() { use bbx_core::StackVec; let mut vec: StackVec<f32, 4> = StackVec::new(); let _ = vec.push(1.0); let _ = vec.push(2.0); let _ = vec.push(3.0); // Immutable iteration for value in &vec { println!("{}", value); } // Mutable iteration for value in &mut vec { *value *= 2.0; } }
Usage in Audio Processing
Per-Block Buffers
#![allow(unused)] fn main() { use bbx_core::StackVec; const MAX_INPUTS: usize = 8; fn collect_inputs(inputs: &[f32]) -> StackVec<f32, MAX_INPUTS> { let mut result = StackVec::new(); for &input in inputs.iter().take(MAX_INPUTS) { let _ = result.push(input); } result } }
Modulation Value Collection
#![allow(unused)] fn main() { use bbx_core::StackVec; const MAX_MODULATORS: usize = 4; struct ModulationContext { values: StackVec<f32, MAX_MODULATORS>, } impl ModulationContext { fn add_modulator(&mut self, value: f32) { let _ = self.values.push(value); } fn total_modulation(&self) -> f32 { self.values.iter().sum() } } }
Comparison with Other Types
| Type | Heap Allocation | Fixed Size | Growable |
|---|---|---|---|
Vec<T> | Yes | No | Yes |
[T; N] | No | Yes | No |
StackVec<T, N> | No | Yes (max) | Yes (up to N) |
ArrayVec<T, N> | No | Yes (max) | Yes (up to N) |
StackVec is similar to arrayvec::ArrayVec but is part of bbx_core with no external dependencies.
Limitations
- Maximum capacity is fixed at compile time
- Capacity is part of the type (
StackVec<T, 4>vsStackVec<T, 8>) - Not suitable for large or unknown-size collections
Random Number Generation
A fast XorShift64 random number generator suitable for audio applications.
Overview
XorShiftRng provides:
- Fast pseudo-random number generation
- Deterministic output (given the same seed)
- Audio-range output methods (-1.0 to 1.0)
API
Creating an RNG
#![allow(unused)] fn main() { use bbx_core::random::XorShiftRng; // Create with a seed let mut rng = XorShiftRng::new(42); // Create with a different seed for different sequences let mut rng2 = XorShiftRng::new(12345); }
Generating Numbers
#![allow(unused)] fn main() { use bbx_core::random::XorShiftRng; let mut rng = XorShiftRng::new(42); // Raw u64 value let raw = rng.next_u64(); // Floating-point 0.0 to 1.0 let normalized = rng.next_f32(); // or next_f64() // Audio sample -1.0 to 1.0 let sample = rng.next_noise_sample(); }
Usage in Audio
White Noise Generator
#![allow(unused)] fn main() { use bbx_core::random::XorShiftRng; struct NoiseGenerator { rng: XorShiftRng, } impl NoiseGenerator { fn new(seed: u64) -> Self { Self { rng: XorShiftRng::new(seed), } } fn process(&mut self, output: &mut [f32]) { for sample in output { *sample = self.rng.next_noise_sample(); } } } }
Randomized Modulation
#![allow(unused)] fn main() { use bbx_core::random::XorShiftRng; struct RandomLfo { rng: XorShiftRng, current_value: f32, target_value: f32, smoothing: f32, } impl RandomLfo { fn new(seed: u64, smoothing: f32) -> Self { let mut rng = XorShiftRng::new(seed); let initial = rng.next_noise_sample(); Self { rng, current_value: initial, target_value: initial, smoothing, } } fn process(&mut self) -> f32 { // Occasionally pick a new target if self.rng.next_f32() < 0.001 { self.target_value = self.rng.next_noise_sample(); } // Smooth toward target self.current_value += (self.target_value - self.current_value) * self.smoothing; self.current_value } } }
Algorithm
XorShift64 is a simple but effective pseudo-random number generator:
#![allow(unused)] fn main() { fn next_u64(&mut self) -> u64 { let mut x = self.state; x ^= x << 13; x ^= x >> 7; x ^= x << 17; self.state = x; x } }
Properties:
- Period: 2^64 - 1
- Fast: ~3 CPU cycles per number
- Good statistical properties for audio use
- Not cryptographically secure
Seeding
Different seeds produce completely different sequences:
#![allow(unused)] fn main() { use bbx_core::random::XorShiftRng; let mut rng1 = XorShiftRng::new(1); let mut rng2 = XorShiftRng::new(2); // Completely different sequences assert_ne!(rng1.next_u64(), rng2.next_u64()); }
For reproducible results (e.g., testing), use a fixed seed.
For unique sequences each run, use a time-based seed:
#![allow(unused)] fn main() { use bbx_core::random::XorShiftRng; use std::time::{SystemTime, UNIX_EPOCH}; let seed = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() as u64; let mut rng = XorShiftRng::new(seed); }
Error Types
Unified error handling across the bbx_audio workspace.
Overview
bbx_core provides common error types used throughout the workspace:
BbxError- Main error enumResult<T>- Type alias forResult<T, BbxError>
BbxError
The main error type:
#![allow(unused)] fn main() { use bbx_core::BbxError; pub enum BbxError { /// Generic error with message Generic(String), /// I/O error wrapper Io(std::io::Error), /// Invalid parameter value InvalidParameter(String), /// Resource not found NotFound(String), /// Operation failed OperationFailed(String), /// Null pointer in FFI context NullPointer, /// Invalid buffer size InvalidBufferSize, /// Graph not prepared for processing GraphNotPrepared, /// Memory allocation failed AllocationFailed, } }
Usage
Creating Errors
#![allow(unused)] fn main() { use bbx_core::BbxError; fn validate_sample_rate(rate: f64) -> Result<(), BbxError> { if rate <= 0.0 { return Err(BbxError::InvalidParameter( format!("Sample rate must be positive, got {}", rate) )); } Ok(()) } }
Using Result Type Alias
#![allow(unused)] fn main() { use bbx_core::{BbxError, Result}; fn load_audio(path: &str) -> Result<Vec<f32>> { // ... implementation Ok(vec![]) } }
Error Propagation
#![allow(unused)] fn main() { use bbx_core::Result; fn process() -> Result<()> { let audio = load_audio("input.wav")?; // Propagate errors with ? save_audio("output.wav", &audio)?; Ok(()) } }
FFI Error Codes
For C FFI, errors are represented as integers:
#![allow(unused)] fn main() { #[repr(C)] pub enum BbxErrorCode { Ok = 0, NullPointer = 1, InvalidParameter = 2, InvalidBufferSize = 3, GraphNotPrepared = 4, AllocationFailed = 5, } }
Convert between error types:
#![allow(unused)] fn main() { use bbx_core::BbxError; impl From<BbxError> for BbxErrorCode { fn from(err: BbxError) -> Self { match err { BbxError::NullPointer => BbxErrorCode::NullPointer, BbxError::InvalidParameter(_) => BbxErrorCode::InvalidParameter, BbxError::InvalidBufferSize => BbxErrorCode::InvalidBufferSize, BbxError::GraphNotPrepared => BbxErrorCode::GraphNotPrepared, BbxError::AllocationFailed => BbxErrorCode::AllocationFailed, _ => BbxErrorCode::InvalidParameter, } } } }
Error Display
BbxError implements Display for human-readable messages:
#![allow(unused)] fn main() { use bbx_core::BbxError; let err = BbxError::InvalidParameter("sample rate".to_string()); println!("{}", err); // "Invalid parameter: sample rate" }
Integration with std::error::Error
BbxError implements std::error::Error, allowing integration with standard error handling:
#![allow(unused)] fn main() { use bbx_core::BbxError; use std::error::Error; fn example() -> std::result::Result<(), Box<dyn Error>> { let result = something_that_returns_bbx_result()?; Ok(()) } }
bbx_draw
Audio visualization primitives for nannou sketches.
Overview
bbx_draw provides embeddable visualizers for audio and DSP applications:
- Real-time visualization with lock-free communication
- Four built-in visualizers for common use cases
- Configurable appearance and behavior
- Compatible with nannou's model-update-view architecture
Installation
[dependencies]
bbx_draw = "0.1"
Features
| Feature | Description |
|---|---|
| Visualizer Trait | Core trait for all visualizers |
| Audio Bridge | Lock-free thread communication |
| Graph Topology | DSP graph layout display |
| Waveform | Oscilloscope-style waveform |
| Spectrum | FFT-based spectrum analyzer |
| MIDI Activity | Piano keyboard note display |
Quick Example
#![allow(unused)] fn main() { use bbx_draw::{GraphTopologyVisualizer, Visualizer}; use bbx_dsp::{blocks::OscillatorBlock, graph::GraphBuilder, waveform::Waveform}; use nannou::prelude::*; fn model(app: &App) -> Model { let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let topology = builder.capture_topology(); Model { visualizer: GraphTopologyVisualizer::new(topology), } } fn update(_app: &App, model: &mut Model, _update: Update) { model.visualizer.update(); } fn view(app: &App, model: &Model, frame: Frame) { let draw = app.draw(); let bounds = app.window_rect(); model.visualizer.draw(&draw, bounds); draw.to_frame(app, &frame).unwrap(); } }
Visualizers
GraphTopologyVisualizer
Displays DSP graph structure with blocks arranged by topological depth. Color-codes blocks by category (generator, effector, modulator, I/O) and shows audio and modulation connections.
WaveformVisualizer
Oscilloscope-style waveform display with zero-crossing trigger detection for stable display. Connects to audio via AudioBridgeConsumer.
SpectrumAnalyzer
FFT-based frequency spectrum display with three modes (bars, line, filled). Supports temporal smoothing and peak hold with configurable decay.
MidiActivityVisualizer
Piano keyboard display showing MIDI note activity. Velocity-based brightness and configurable decay animation after note-off.
Threading Model
┌─────────────────────┐ SPSC Ring Buffer ┌─────────────────────┐
│ Audio Thread │ ────────────────────────────▶ │ nannou Thread │
│ (rodio callback) │ AudioFrame packets │ (model/update/view)│
│ │ │ │
│ try_send() │ │ try_pop() │
│ (non-blocking) │ │ (consume all) │
└─────────────────────┘ └─────────────────────┘
The audio thread uses non-blocking try_send(). Frames are dropped if the buffer is full, which is acceptable for visualization purposes.
See Visualization Threading for details.
Visualizer Trait
The core trait that all visualizers implement, following a two-phase update/draw pattern.
Trait Definition
#![allow(unused)] fn main() { pub trait Visualizer { /// Update internal state (called each frame before drawing). fn update(&mut self); /// Draw the visualization within the given bounds. fn draw(&self, draw: &nannou::Draw, bounds: Rect); } }
Methods
update
#![allow(unused)] fn main() { fn update(&mut self); }
Called once per frame before drawing. Visualizers should:
- Consume data from their SPSC bridges
- Process incoming audio frames or MIDI messages
- Update internal buffers and state
Keep this method efficient as it runs on the visualization thread at frame rate (typically 60 Hz).
draw
#![allow(unused)] fn main() { fn draw(&self, draw: &nannou::Draw, bounds: Rect); }
Renders the visualization within the given bounds rectangle.
| Parameter | Description |
|---|---|
draw | nannou's Draw API for rendering |
bounds | Rectangle defining the render area |
The bounds parameter enables layout composition. Multiple visualizers can render side-by-side by subdividing the window rectangle.
Integration with nannou
The trait maps directly to nannou's model-update-view pattern:
#![allow(unused)] fn main() { use bbx_draw::{Visualizer, WaveformVisualizer, audio_bridge}; struct Model { visualizer: WaveformVisualizer, } fn model(app: &App) -> Model { let (_producer, consumer) = audio_bridge(16); Model { visualizer: WaveformVisualizer::new(consumer), } } fn update(_app: &App, model: &mut Model, _update: Update) { model.visualizer.update(); } fn view(app: &App, model: &Model, frame: Frame) { let draw = app.draw(); let bounds = app.window_rect(); model.visualizer.draw(&draw, bounds); draw.to_frame(app, &frame).unwrap(); } }
Multiple Visualizers
Arrange visualizers using Rect subdivision:
#![allow(unused)] fn main() { fn view(app: &App, model: &Model, frame: Frame) { let draw = app.draw(); let win = app.window_rect(); // Split window horizontally let (left, right) = win.split_left(win.w() * 0.5); model.waveform.draw(&draw, left); model.spectrum.draw(&draw, right); draw.to_frame(app, &frame).unwrap(); } }
Implementing a Custom Visualizer
#![allow(unused)] fn main() { use bbx_draw::Visualizer; use nannou::prelude::*; pub struct LevelMeter { level: f32, consumer: AudioBridgeConsumer, } impl Visualizer for LevelMeter { fn update(&mut self) { while let Some(frame) = self.consumer.try_pop() { let peak = frame.samples.iter() .map(|s| s.abs()) .fold(0.0f32, f32::max); self.level = self.level.max(peak) * 0.95; // decay } } fn draw(&self, draw: &Draw, bounds: Rect) { let height = bounds.h() * self.level; draw.rect() .xy(bounds.mid_bottom() + vec2(0.0, height * 0.5)) .w_h(bounds.w(), height) .color(GREEN); } } }
Real-Time Safety
The update() method runs on the visualization thread, not the audio thread. However, follow these guidelines:
- Drain all available data from bridges each frame
- Avoid allocations in tight loops
- Use fixed-size buffers where possible
- Keep processing lightweight (60 Hz budget)
Audio Bridge
Lock-free communication between audio and visualization threads.
Overview
The audio bridge provides thread-safe, real-time-safe transfer of audio data using bbx_core::SpscRingBuffer. The producer runs in the audio thread, while the consumer runs in the visualization thread.
Creating a Bridge
#![allow(unused)] fn main() { use bbx_draw::{audio_bridge, AudioBridgeProducer, AudioBridgeConsumer}; // Create producer/consumer pair with capacity of 16 frames let (producer, consumer) = audio_bridge(16); }
The capacity parameter determines how many audio frames can be buffered. Typical values are 4-16 frames.
AudioBridgeProducer
Used in the audio thread to send frames.
Methods
| Method | Description |
|---|---|
try_send(&mut self, frame: Frame) -> bool | Send a frame (non-blocking) |
is_full(&self) -> bool | Check if buffer is full |
Usage
#![allow(unused)] fn main() { // In audio callback let frame = Frame::new(&samples, 44100, 2); producer.try_send(frame); // Don't block if full }
try_send() returns false if the buffer is full. Dropping frames is acceptable for visualization.
AudioBridgeConsumer
Used in the visualization thread to receive frames.
Methods
| Method | Description |
|---|---|
try_pop(&mut self) -> Option<Frame> | Pop one frame (non-blocking) |
is_empty(&self) -> bool | Check if buffer is empty |
len(&self) -> usize | Number of available frames |
Usage
#![allow(unused)] fn main() { // In visualizer update() while let Some(frame) = consumer.try_pop() { // Process frame for sample in frame.samples.iter() { // ... } } }
MIDI Bridge
For MIDI visualization, use the MIDI bridge:
#![allow(unused)] fn main() { use bbx_draw::{midi_bridge, MidiBridgeProducer, MidiBridgeConsumer}; let (producer, consumer) = midi_bridge(256); }
MidiBridgeProducer
| Method | Description |
|---|---|
try_send(&mut self, msg: MidiMessage) -> bool | Send a MIDI message |
is_full(&self) -> bool | Check if buffer is full |
MidiBridgeConsumer
| Method | Description |
|---|---|
drain(&mut self) -> Vec<MidiMessage> | Get all available messages |
is_empty(&self) -> bool | Check if buffer is empty |
Capacity Guidelines
| Bridge Type | Recommended Capacity | Notes |
|---|---|---|
| Audio | 4-16 frames | Higher = more latency |
| MIDI | 64-256 messages | MIDI is sparse, bursty |
Tradeoffs
Too small: Frame drops cause visual stuttering
Too large: Increased latency between audio and visual
Complete Example
use bbx_draw::{audio_bridge, AudioFrame, WaveformVisualizer, Visualizer}; use std::thread; fn main() { // Create bridge let (mut producer, consumer) = audio_bridge(16); // Spawn audio thread thread::spawn(move || { loop { let samples = generate_audio(); let frame = AudioFrame::new(&samples, 44100, 2); producer.try_send(frame); } }); // Create visualizer with consumer let mut visualizer = WaveformVisualizer::new(consumer); // In visualization loop visualizer.update(); // Consumes frames from bridge // visualizer.draw(...); }
Frame Type
AudioFrame (alias for bbx_dsp::Frame) contains:
| Field | Type | Description |
|---|---|---|
samples | StackVec<S, MAX_FRAME_SAMPLES> | Audio samples |
sample_rate | u32 | Sample rate in Hz |
channels | usize | Number of channels |
The StackVec uses stack allocation for real-time safety.
Graph Topology Visualizer
Displays DSP graph structure with blocks arranged by topological depth.
Overview
GraphTopologyVisualizer renders a static snapshot of a DSP graph, showing:
- Blocks colored by category (generator, effector, modulator, I/O)
- Audio connections as solid bezier curves
- Modulation connections as dashed lines with parameter labels
- Block names as text labels
Creating a Visualizer
With Default Configuration
#![allow(unused)] fn main() { use bbx_draw::GraphTopologyVisualizer; use bbx_dsp::{blocks::OscillatorBlock, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let topology = builder.capture_topology(); let visualizer = GraphTopologyVisualizer::new(topology); }
With Custom Configuration
#![allow(unused)] fn main() { use bbx_draw::{GraphTopologyVisualizer, config::GraphTopologyConfig}; let config = GraphTopologyConfig { block_width: 150.0, block_height: 60.0, show_arrows: false, ..Default::default() }; let visualizer = GraphTopologyVisualizer::with_config(topology, config); }
Configuration Options
GraphTopologyConfig
| Option | Type | Default | Description |
|---|---|---|---|
block_width | f32 | 120.0 | Width of block rectangles |
block_height | f32 | 50.0 | Height of block rectangles |
horizontal_spacing | f32 | 80.0 | Space between depth columns |
vertical_spacing | f32 | 30.0 | Space between blocks in column |
Colors
| Option | Type | Default | Description |
|---|---|---|---|
generator_color | Rgb | Blue | Generator block fill |
effector_color | Rgb | Green | Effector block fill |
modulator_color | Rgb | Purple | Modulator block fill |
io_color | Rgb | Orange | I/O block fill |
audio_connection_color | Rgb | Gray | Audio connection lines |
modulation_connection_color | Rgb | Pink | Modulation connection lines |
text_color | Rgb | White | Block label text |
Connections
| Option | Type | Default | Description |
|---|---|---|---|
audio_connection_weight | f32 | 2.0 | Audio line thickness |
modulation_connection_weight | f32 | 1.5 | Modulation line thickness |
show_arrows | bool | true | Show directional arrows |
arrow_size | f32 | 8.0 | Arrow head size |
dash_length | f32 | 8.0 | Modulation dash length |
dash_gap | f32 | 4.0 | Gap between dashes |
Layout Algorithm
Blocks are positioned using topological depth:
- Source blocks (no inputs) have depth 0
- Each block's depth is
max(input depths) + 1 - Blocks are arranged left-to-right by depth
- Blocks at the same depth are stacked vertically
This ensures signal flow reads left-to-right.
Updating Topology
For dynamic graphs, update the topology at runtime:
#![allow(unused)] fn main() { // After modifying the graph let new_topology = builder.capture_topology(); visualizer.set_topology(new_topology); }
Example
use bbx_draw::{GraphTopologyVisualizer, Visualizer}; use bbx_dsp::{blocks::{GainBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; use nannou::prelude::*; struct Model { visualizer: GraphTopologyVisualizer, } fn model(app: &App) -> Model { app.new_window().view(view).build().unwrap(); 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 topology = builder.capture_topology(); Model { visualizer: GraphTopologyVisualizer::new(topology), } } fn update(_app: &App, model: &mut Model, _update: Update) { model.visualizer.update(); } fn view(app: &App, model: &Model, frame: Frame) { let draw = app.draw(); draw.background().color(BLACK); model.visualizer.draw(&draw, app.window_rect()); draw.to_frame(app, &frame).unwrap(); } fn main() { nannou::app(model).update(update).run(); }
Waveform Visualizer
Spectrum Analyzer
MIDI Activity Visualizer
bbx_dsp
A block-based audio DSP system for building signal processing graphs.
Overview
bbx_dsp is the core DSP crate providing:
- Graph-based architecture for connecting DSP blocks
- Automatic topological sorting for correct execution order
- Real-time safe processing with stack-allocated buffers
- Parameter modulation via LFOs and envelopes
Installation
[dependencies]
bbx_dsp = "0.1"
Features
| Feature | Description |
|---|---|
| Graph | Block graph and builder |
| Block Trait | Interface for DSP blocks |
| BlockType | Enum wrapping all blocks |
| Sample | Re-exported from bbx_core |
| DspContext | Processing context |
| Parameters | Modulation system |
Quick Example
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{GainBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; // Create a graph: 44.1kHz, 512 samples, stereo let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Add blocks let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let gain = builder.add(GainBlock::new(-6.0, None)); // Connect: oscillator -> gain builder.connect(osc, 0, gain, 0); // Build the graph let mut graph = builder.build(); // Process audio let mut left = vec![0.0f32; 512]; let mut right = vec![0.0f32; 512]; let mut outputs: [&mut [f32]; 2] = [&mut left, &mut right]; graph.process_buffers(&mut outputs); }
Block Categories
Generators
Blocks that create audio signals:
OscillatorBlock- Waveform generator
Effectors
Blocks that process audio:
GainBlock- Level controlPannerBlock- Stereo panningOverdriveBlock- DistortionDcBlockerBlock- DC removalChannelRouterBlock- Channel routing
Modulators
Blocks that generate control signals:
LfoBlock- Low-frequency oscillatorEnvelopeBlock- ADSR envelope
I/O
Blocks for input/output:
FileInputBlock- Audio file inputFileOutputBlock- Audio file outputOutputBlock- Graph output
Architecture
The DSP system uses a pull model:
GraphBuildercollects blocks and connectionsbuild()creates an optimizedGraph- Topological sorting determines execution order
process_buffers()runs all blocks in order
See DSP Graph Architecture for details.
Graph and GraphBuilder
The core types for building and processing DSP graphs.
GraphBuilder
GraphBuilder provides a fluent API for constructing DSP graphs.
Creating a Builder
#![allow(unused)] fn main() { use bbx_dsp::graph::GraphBuilder; let mut builder = GraphBuilder::<f32>::new( 44100.0, // sample rate 512, // buffer size 2, // channels ); }
Creating with Channel Layout
For multi-channel support beyond stereo, use with_layout:
#![allow(unused)] fn main() { use bbx_dsp::{channel::ChannelLayout, graph::GraphBuilder}; // 5.1 surround graph let builder = GraphBuilder::<f32>::with_layout(44100.0, 512, ChannelLayout::Surround51); // First-order ambisonics graph let builder = GraphBuilder::<f32>::with_layout(44100.0, 512, ChannelLayout::AmbisonicFoa); }
Adding Blocks
Use the generic add() method to add any block type:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{ DcBlockerBlock, EnvelopeBlock, GainBlock, LfoBlock, OscillatorBlock, OverdriveBlock, PannerBlock, }, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Oscillator: frequency, waveform, seed let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Overdrive: drive, level, tone, sample_rate let overdrive = builder.add(OverdriveBlock::new(3.0, 1.0, 0.8, 44100.0)); // LFO: frequency, depth, waveform, seed let lfo = builder.add(LfoBlock::new(5.0, 0.5, Waveform::Sine, None)); // Envelope: attack, decay, sustain, release let env = builder.add(EnvelopeBlock::new(0.01, 0.1, 0.7, 0.3)); // Gain: level_db, base_gain let gain = builder.add(GainBlock::new(-6.0, None)); // Panner: position let pan = builder.add(PannerBlock::new(0.0)); // DC blocker: enabled let dc = builder.add(DcBlockerBlock::new(true)); }
Multi-Channel Blocks
Use the generic add() method for multi-channel routing blocks:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{ AmbisonicDecoderBlock, ChannelMergerBlock, ChannelSplitterBlock, MatrixMixerBlock, PannerBlock, }, channel::ChannelLayout, graph::GraphBuilder, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 6); // Channel routing let splitter = builder.add(ChannelSplitterBlock::new(6)); // Split to mono outputs let merger = builder.add(ChannelMergerBlock::new(6)); // Merge mono inputs let mixer = builder.add(MatrixMixerBlock::new(4, 2)); // NxM matrix mixer // Surround and ambisonic panning let surround = builder.add(PannerBlock::surround(ChannelLayout::Surround51)); let ambisonic = builder.add(PannerBlock::ambisonic(1)); // FOA encoder // Ambisonic decoding let decoder = builder.add(AmbisonicDecoderBlock::new(1, ChannelLayout::Stereo)); }
Connecting Blocks
Connect block outputs to inputs:
#![allow(unused)] fn main() { // connect(from_block, from_port, to_block, to_port) builder.connect(osc, 0, gain, 0); builder.connect(gain, 0, pan, 0); }
Modulation
Use modulate() to connect modulators to parameters:
#![allow(unused)] fn main() { // modulate(source, target, parameter_name) builder.modulate(lfo, osc, "frequency"); builder.modulate(lfo, gain, "level"); }
Building the Graph
#![allow(unused)] fn main() { let graph = builder.build(); }
The build process:
- Validates all connections
- Performs topological sorting
- Allocates processing buffers
- Returns an optimized
Graph
Graph
Graph is the compiled, ready-to-process DSP graph.
Processing Audio
#![allow(unused)] fn main() { let mut left = vec![0.0f32; 512]; let mut right = vec![0.0f32; 512]; let mut outputs: [&mut [f32]; 2] = [&mut left, &mut right]; graph.process_buffers(&mut outputs); }
Preparing for Playback
Call prepare_for_playback() before processing:
#![allow(unused)] fn main() { graph.prepare_for_playback(); }
Note: GraphBuilder::build() calls this automatically.
Finalization
For file output, call finalize() to flush buffers:
#![allow(unused)] fn main() { graph.finalize(); }
BlockId
A handle to a block in the graph:
#![allow(unused)] fn main() { let osc: BlockId = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); }
BlockId is used for:
- Connecting blocks
- Referencing modulators
- Accessing block state (if needed)
Connection Rules
- Each output can connect to multiple inputs
- Each input can receive multiple connections (summed)
- Cycles are not allowed (topological sorting will fail)
- Unconnected blocks are still processed
Example: Complex Graph
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{DcBlockerBlock, GainBlock, OscillatorBlock, OverdriveBlock, PannerBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Create two oscillators let osc1 = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); let osc2 = builder.add(OscillatorBlock::new(441.0, Waveform::Saw, None)); // Slight detune // Mix them let mixer = builder.add(GainBlock::new(-6.0, None)); builder.connect(osc1, 0, mixer, 0); builder.connect(osc2, 0, mixer, 0); // Add effects let overdrive = builder.add(OverdriveBlock::new(3.0, 1.0, 0.8, 44100.0)); let dc_blocker = builder.add(DcBlockerBlock::new(true)); let pan = builder.add(PannerBlock::new(0.0)); // Chain effects builder.connect(mixer, 0, overdrive, 0); builder.connect(overdrive, 0, dc_blocker, 0); builder.connect(dc_blocker, 0, pan, 0); let graph = builder.build(); }
Block Trait
The Block trait defines the interface for DSP processing blocks.
Trait Definition
#![allow(unused)] fn main() { pub trait Block<S: Sample> { /// Prepare for playback with given context fn prepare(&mut self, _context: &DspContext) {} /// Process audio through the block fn process( &mut self, inputs: &[&[S]], outputs: &mut [&mut [S]], modulation_values: &[S], context: &DspContext, ); /// Number of input ports fn input_count(&self) -> usize; /// Number of output ports fn output_count(&self) -> usize; /// Modulation outputs provided by this block fn modulation_outputs(&self) -> &[ModulationOutput]; /// How this block handles multi-channel audio fn channel_config(&self) -> ChannelConfig { ChannelConfig::Parallel } } }
Implementing a Custom Block
#![allow(unused)] fn main() { use bbx_dsp::{ block::Block, context::DspContext, parameter::ModulationOutput, sample::Sample, }; struct MyGainBlock<S: Sample> { gain: S, } impl<S: Sample> MyGainBlock<S> { fn new(gain_db: f64) -> Self { let linear = (10.0_f64).powf(gain_db / 20.0); Self { gain: S::from_f64(linear), } } } impl<S: Sample> Block<S> for MyGainBlock<S> { fn process( &mut self, inputs: &[&[S]], outputs: &mut [&mut [S]], _modulation_values: &[S], context: &DspContext, ) { for ch in 0..inputs.len().min(outputs.len()) { for i in 0..context.buffer_size { outputs[ch][i] = inputs[ch][i] * self.gain; } } } fn input_count(&self) -> usize { 1 } fn output_count(&self) -> usize { 1 } fn modulation_outputs(&self) -> &[ModulationOutput] { &[] } } }
Process Method
The process method receives:
inputs- Slice of input channel buffersoutputs- Mutable slice of output channel buffersmodulation_values- Values from connected modulator blockscontext- Processing context (sample rate, buffer size)
Input/Output Layout
#![allow(unused)] fn main() { fn process( &mut self, inputs: &[&[S]], // inputs[channel][sample] outputs: &mut [&mut [S]], // outputs[channel][sample] modulation_values: &[S], context: &DspContext, ) { // inputs.len() = number of input channels // inputs[0].len() = number of samples per channel } }
Modulation Values
For blocks that receive modulation:
#![allow(unused)] fn main() { fn process( &mut self, inputs: &[&[S]], outputs: &mut [&mut [S]], modulation_values: &[S], context: &DspContext, ) { // modulation_values[0] = value from first connected modulator // Use for per-block (not per-sample) modulation let mod_depth = modulation_values.get(0).copied().unwrap_or(S::ZERO); } }
Port Counts
input_count / output_count
Return the number of audio ports:
#![allow(unused)] fn main() { // Mono effect fn input_count(&self) -> usize { 1 } fn output_count(&self) -> usize { 1 } // Stereo panner fn input_count(&self) -> usize { 1 } fn output_count(&self) -> usize { 2 } // Mixer (4 inputs, 1 output) fn input_count(&self) -> usize { 4 } fn output_count(&self) -> usize { 1 } }
modulation_outputs
For modulator blocks (LFO, envelope), return a slice describing each modulation output:
#![allow(unused)] fn main() { fn modulation_outputs(&self) -> &[ModulationOutput] { &[ModulationOutput { name: "LFO", min_value: -1.0, max_value: 1.0, }] } }
For non-modulator blocks, return an empty slice:
#![allow(unused)] fn main() { fn modulation_outputs(&self) -> &[ModulationOutput] { &[] } }
Lifecycle Methods
prepare
Called when audio specs change:
#![allow(unused)] fn main() { fn prepare(&mut self, context: &DspContext) { // Recalculate filter coefficients for new sample rate self.coefficient = calculate_coefficient(context.sample_rate); } }
Channel Configuration
The channel_config() method declares how a block handles multi-channel audio:
Parallel (default)
Process each channel independently through the same algorithm:
#![allow(unused)] fn main() { fn channel_config(&self) -> ChannelConfig { ChannelConfig::Parallel } }
Use for: filters, gain, distortion, DC blockers.
Explicit
Block handles channel routing internally:
#![allow(unused)] fn main() { fn channel_config(&self) -> ChannelConfig { ChannelConfig::Explicit } }
Use for: panners, mixers, splitters, mergers, decoders.
Blocks with Explicit config typically have different input/output counts and implement custom routing logic.
Real-Time Safety
Blocks should follow real-time safety guidelines:
- Avoid allocating memory in
process() - Use atomic operations for cross-thread communication
- Never block or lock
BlockType Enum
BlockType is an enum that wraps all concrete block implementations.
Overview
The graph system uses BlockType to store heterogeneous blocks:
#![allow(unused)] fn main() { pub enum BlockType<S: Sample> { // I/O FileInput(FileInputBlock<S>), FileOutput(FileOutputBlock<S>), Output(OutputBlock<S>), // Generators Oscillator(OscillatorBlock<S>), // Effectors ChannelRouter(ChannelRouterBlock<S>), DcBlocker(DcBlockerBlock<S>), Gain(GainBlock<S>), LowPassFilter(LowPassFilterBlock<S>), Overdrive(OverdriveBlock<S>), Panner(PannerBlock<S>), Vca(VcaBlock<S>), // Modulators Envelope(EnvelopeBlock<S>), Lfo(LfoBlock<S>), } }
Usage
BlockType is primarily used internally by the graph system. Users interact with blocks through GraphBuilder:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{GainBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // These return BlockId, not BlockType let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let gain = builder.add(GainBlock::new(-6.0, None)); }
Block Trait Implementation
BlockType implements Block by delegating to the wrapped type:
#![allow(unused)] fn main() { impl<S: Sample> BlockType<S> { fn process( &mut self, inputs: &[&[S]], outputs: &mut [&mut [S]], modulation_values: &[S], context: &DspContext, ) { match self { BlockType::Oscillator(b) => b.process(inputs, outputs, modulation_values, context), BlockType::Gain(b) => b.process(inputs, outputs, modulation_values, context), // ... etc } } fn input_count(&self) -> usize { match self { BlockType::Oscillator(b) => b.input_count(), BlockType::Gain(b) => b.input_count(), // ... etc } } // ... other methods } }
Adding Custom Blocks
To add a custom block type, you would need to:
- Implement
Block<S>for your block - Add a variant to
BlockType - Update all match arms in
BlockType'sBlockimplementation - Add a builder method to
GraphBuilder
For plugin development, consider using PluginDsp instead, which doesn't require modifying BlockType.
Pattern Matching
If you need to access the inner block type:
#![allow(unused)] fn main() { use bbx_dsp::block::BlockType; fn get_oscillator_frequency<S: Sample>(block: &BlockType<S>) -> Option<f64> { match block { BlockType::Oscillator(osc) => Some(osc.frequency()), _ => None, } } }
Block Categories
Blocks are organized into categories:
| Category | Variants |
|---|---|
| Generators | Oscillator |
| Effectors | ChannelRouter, DcBlocker, Gain, LowPassFilter, Overdrive, Panner, Vca |
| Modulators | Envelope, Lfo |
| I/O | FileInput, FileOutput, Output |
DspContext
DspContext holds audio processing parameters passed to DSP blocks.
Definition
#![allow(unused)] fn main() { pub struct DspContext { /// Sample rate in Hz pub sample_rate: f64, /// Number of samples per buffer pub buffer_size: usize, /// Number of audio channels pub num_channels: usize, } }
Creating a Context
#![allow(unused)] fn main() { use bbx_dsp::context::DspContext; let context = DspContext::new(44100.0, 512, 2); }
Default Constants
#![allow(unused)] fn main() { use bbx_dsp::context::{DEFAULT_SAMPLE_RATE, DEFAULT_BUFFER_SIZE}; // DEFAULT_SAMPLE_RATE = 44100.0 // DEFAULT_BUFFER_SIZE = 512 }
Usage in Blocks
Context is passed to all block methods:
#![allow(unused)] fn main() { use bbx_dsp::{block::Block, context::DspContext, sample::Sample}; impl<S: Sample> Block<S> for MyBlock<S> { fn process( &mut self, inputs: &[&[S]], outputs: &mut [&mut [S]], context: &DspContext, modulation: &[S], ) { // Use context values let sample_rate = context.sample_rate; let buffer_size = context.buffer_size; for i in 0..buffer_size { // Process samples } } fn prepare(&mut self, context: &DspContext) { // Recalculate coefficients based on sample rate self.coefficient = self.calculate_coefficient(context.sample_rate); } } }
Common Patterns
Sample Rate Dependent Calculations
#![allow(unused)] fn main() { use bbx_dsp::context::DspContext; fn calculate_filter_coefficient(cutoff_hz: f64, context: &DspContext) -> f64 { let normalized_freq = cutoff_hz / context.sample_rate; // Calculate coefficient... 1.0 - (-2.0 * f64::PI * normalized_freq).exp() } }
Time-Based Parameters
#![allow(unused)] fn main() { use bbx_dsp::context::DspContext; fn samples_for_milliseconds(ms: f64, context: &DspContext) -> usize { (ms * context.sample_rate / 1000.0) as usize } fn samples_for_seconds(seconds: f64, context: &DspContext) -> usize { (seconds * context.sample_rate) as usize } }
Phase Increment
#![allow(unused)] fn main() { use bbx_dsp::context::DspContext; fn phase_increment(frequency: f64, context: &DspContext) -> f64 { frequency / context.sample_rate } }
Context Changes
When audio specs change (e.g., DAW sample rate changes):
prepare()is called on all blocks- Blocks should recalculate time-dependent values
reset()may also be called to clear state
#![allow(unused)] fn main() { fn prepare(&mut self, context: &DspContext) { // Sample rate changed - recalculate everything self.phase_increment = self.frequency / context.sample_rate; self.filter.recalculate_coefficients(context.sample_rate); // Resize buffers if needed if self.delay_buffer.len() != context.buffer_size { self.delay_buffer.resize(context.buffer_size, 0.0); } } }
Parameter System
The bbx_dsp parameter system supports static values and modulation.
Parameter Type
#![allow(unused)] fn main() { pub enum Parameter<S: Sample> { /// Static value Constant(S), /// Modulated by a block (e.g., LFO) Modulated(BlockId), } }
Constant Parameters
Parameters with fixed values:
#![allow(unused)] fn main() { use bbx_dsp::parameter::Parameter; let gain = Parameter::Constant(-6.0_f32); let frequency = Parameter::Constant(440.0_f32); }
Modulated Parameters
Parameters controlled by modulator blocks use the GraphBuilder::modulate() method:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{LfoBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Create an LFO (frequency, depth, waveform, seed) let lfo = builder.add(LfoBlock::new(5.0, 0.3, Waveform::Sine, None)); // Create an oscillator let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Modulate oscillator frequency with the LFO builder.modulate(lfo, osc, "frequency"); }
Modulation Flow
- Modulator blocks (LFO, Envelope) output control values
- These values are collected during graph processing
- Target blocks receive values in the
modulation_valuesparameter
#![allow(unused)] fn main() { fn process( &mut self, inputs: &[&[S]], outputs: &mut [&mut [S]], modulation_values: &[S], // Modulation values from connected blocks context: &DspContext, ) { let mod_value = modulation_values.get(0).copied().unwrap_or(S::ZERO); // Use mod_value to affect processing } }
Modulation Depth
Blocks interpret modulation values differently:
Frequency Modulation
#![allow(unused)] fn main() { // LFO range: -1.0 to 1.0 (scaled by depth) // This maps to frequency deviation let mod_range = 0.1; // +/-10% frequency change let modulated_freq = base_freq * (1.0 + mod_value * mod_range); }
Amplitude Modulation
#![allow(unused)] fn main() { // LFO directly scales amplitude let modulated_amp = base_amp * (1.0 + mod_value); }
Bipolar vs Unipolar
Some modulators are bipolar (-1 to 1), others unipolar (0 to 1):
#![allow(unused)] fn main() { // Convert bipolar to unipolar let unipolar = (bipolar + 1.0) * 0.5; // 0.0 to 1.0 // Convert unipolar to bipolar let bipolar = unipolar * 2.0 - 1.0; // -1.0 to 1.0 }
Example: Tremolo
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{GainBlock, LfoBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Audio source let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Tremolo LFO (6 Hz, full depth) let lfo = builder.add(LfoBlock::new(6.0, 1.0, Waveform::Sine, None)); // Gain block let gain = builder.add(GainBlock::new(-6.0, None)); builder.connect(osc, 0, gain, 0); // Modulate gain level with LFO builder.modulate(lfo, gain, "level"); let graph = builder.build(); }
Example: Vibrato
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{LfoBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Vibrato LFO (5 Hz, moderate depth) let lfo = builder.add(LfoBlock::new(5.0, 0.3, Waveform::Sine, None)); // Oscillator let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Modulate oscillator frequency builder.modulate(lfo, osc, "frequency"); let graph = builder.build(); }
bbx_plugin
Plugin integration crate for JUCE and other C/C++ frameworks.
Overview
bbx_plugin provides:
- Re-exports of
bbx_dspfor single-dependency usage PluginDsptrait for plugin DSP implementationsbbx_plugin_ffi!macro for generating C FFI exports- Parameter definition and code generation utilities
Installation
[package]
name = "my_plugin_dsp"
edition = "2024"
[lib]
crate-type = ["staticlib"]
[dependencies]
bbx_plugin = "0.1"
Features
| Feature | Description |
|---|---|
| PluginDsp Trait | Interface for plugin DSP |
| FFI Macro | Generate C exports |
| Parameter Definitions | JSON and programmatic params |
Quick Example
#![allow(unused)] fn main() { use bbx_plugin::{PluginDsp, DspContext, bbx_plugin_ffi}; pub struct MyPlugin { gain: f32, } impl Default for MyPlugin { fn default() -> Self { Self::new() } } impl PluginDsp for MyPlugin { fn new() -> Self { Self { gain: 1.0 } } fn prepare(&mut self, _context: &DspContext) {} fn reset(&mut self) {} fn apply_parameters(&mut self, params: &[f32]) { self.gain = params.get(0).copied().unwrap_or(1.0); } fn process( &mut self, inputs: &[&[f32]], outputs: &mut [&mut [f32]], context: &DspContext, ) { for ch in 0..inputs.len().min(outputs.len()) { for i in 0..context.buffer_size { outputs[ch][i] = inputs[ch][i] * self.gain; } } } } // Generate FFI exports bbx_plugin_ffi!(MyPlugin); }
Re-exports
bbx_plugin re-exports key types from bbx_dsp:
#![allow(unused)] fn main() { // All available from bbx_plugin directly use bbx_plugin::{ PluginDsp, DspContext, blocks::{GainBlock, PannerBlock, OscillatorBlock}, waveform::Waveform, }; }
JUCE Integration
For complete integration guide, see JUCE Plugin Integration.
PluginDsp Trait
The PluginDsp trait defines the interface for plugin DSP implementations.
Definition
#![allow(unused)] fn main() { pub trait PluginDsp: Default + Send + 'static { fn new() -> Self; fn prepare(&mut self, context: &DspContext); fn reset(&mut self); fn apply_parameters(&mut self, params: &[f32]); fn process(&mut self, inputs: &[&[f32]], outputs: &mut [&mut [f32]], midi_events: &[MidiEvent], context: &DspContext); // Optional MIDI callbacks with sample-accurate timing (default no-ops) fn note_on(&mut self, note: u8, velocity: u8, sample_offset: u32) {} fn note_off(&mut self, note: u8, sample_offset: u32) {} fn control_change(&mut self, cc: u8, value: u8, sample_offset: u32) {} fn pitch_bend(&mut self, value: i16, sample_offset: u32) {} } }
The sample_offset parameter in MIDI callbacks indicates the sample position within the current buffer where the event occurs, enabling sample-accurate MIDI timing.
Trait Bounds
Default- Required for FFI instantiationSend- Safe to transfer to audio thread'static- No borrowed references
Methods
new
Create a new instance with default state:
#![allow(unused)] fn main() { fn new() -> Self { Self { gain: 0.0, filter_state: 0.0, } } }
prepare
Called when audio specs change:
#![allow(unused)] fn main() { fn prepare(&mut self, context: &DspContext) { // Update sample-rate dependent calculations self.filter_coefficient = calculate_coefficient( self.cutoff_hz, context.sample_rate, ); } }
reset
Clear all DSP state:
#![allow(unused)] fn main() { fn reset(&mut self) { self.filter_state = 0.0; self.delay_buffer.fill(0.0); } }
apply_parameters
Map parameter array to DSP state:
#![allow(unused)] fn main() { fn apply_parameters(&mut self, params: &[f32]) { if let Some(&gain) = params.get(0) { self.gain = gain; } if let Some(&pan) = params.get(1) { self.pan = pan; } } }
process
Process audio buffers with MIDI events:
#![allow(unused)] fn main() { fn process( &mut self, inputs: &[&[f32]], outputs: &mut [&mut [f32]], midi_events: &[MidiEvent], context: &DspContext, ) { // Handle MIDI for synthesizers for event in midi_events { match event.message.get_status() { MidiMessageStatus::NoteOn => self.note_on(event.message.get_note().unwrap(), event.message.get_velocity().unwrap()), MidiMessageStatus::NoteOff => self.note_off(event.message.get_note().unwrap()), _ => {} } } // Process audio for ch in 0..inputs.len().min(outputs.len()) { for i in 0..context.buffer_size { outputs[ch][i] = inputs[ch][i] * self.gain; } } } }
Implementation Requirements
Default Implementation
You must implement Default:
#![allow(unused)] fn main() { impl Default for MyPlugin { fn default() -> Self { Self::new() } } }
No Allocations in process()
The process method runs on the audio thread. Avoid:
- Memory allocation (
Vec::new(),Box::new()) - Locking (
Mutex::lock()) - I/O operations
Thread Safety
The plugin may be moved between threads. Use:
AtomicF32for cross-thread values- SPSC queues for message passing
- No thread-local storage
Complete Example
#![allow(unused)] fn main() { use bbx_plugin::{PluginDsp, DspContext, bbx_plugin_ffi}; use bbx_midi::MidiEvent; const PARAM_GAIN: usize = 0; const PARAM_PAN: usize = 1; pub struct StereoGain { gain: f32, pan: f32, } impl Default for StereoGain { fn default() -> Self { Self::new() } } impl PluginDsp for StereoGain { fn new() -> Self { Self { gain: 1.0, pan: 0.0 } } fn prepare(&mut self, _context: &DspContext) { // Nothing sample-rate dependent } fn reset(&mut self) { // No state to reset } fn apply_parameters(&mut self, params: &[f32]) { self.gain = 10.0_f32.powf(params.get(PARAM_GAIN).copied().unwrap_or(0.0) / 20.0); self.pan = params.get(PARAM_PAN).copied().unwrap_or(0.0); } fn process( &mut self, inputs: &[&[f32]], outputs: &mut [&mut [f32]], _midi_events: &[MidiEvent], context: &DspContext, ) { let pan_rad = self.pan * f32::FRAC_PI_4; let left_gain = self.gain * (f32::FRAC_PI_4 - pan_rad).cos(); let right_gain = self.gain * (f32::FRAC_PI_4 + pan_rad).cos(); if inputs.len() >= 2 && outputs.len() >= 2 { for i in 0..context.buffer_size { outputs[0][i] = inputs[0][i] * left_gain; outputs[1][i] = inputs[1][i] * right_gain; } } } } bbx_plugin_ffi!(StereoGain); }
FFI Macro
The bbx_plugin_ffi! macro generates C FFI exports for a PluginDsp implementation.
Usage
#![allow(unused)] fn main() { use bbx_plugin::{PluginDsp, bbx_plugin_ffi}; pub struct MyPlugin { /* ... */ } impl PluginDsp for MyPlugin { /* ... */ } // Generate all FFI functions bbx_plugin_ffi!(MyPlugin); }
Generated Functions
The macro generates these extern "C" functions:
bbx_graph_create
BbxGraph* bbx_graph_create(void);
Creates a new DSP instance. Returns NULL on allocation failure.
bbx_graph_destroy
void bbx_graph_destroy(BbxGraph* handle);
Destroys the DSP instance. Safe to call with NULL.
bbx_graph_prepare
BbxError bbx_graph_prepare(
BbxGraph* handle,
double sample_rate,
uint32_t buffer_size,
uint32_t num_channels
);
Prepares for playback. Calls PluginDsp::prepare().
bbx_graph_reset
BbxError bbx_graph_reset(BbxGraph* handle);
Resets DSP state. Calls PluginDsp::reset().
bbx_graph_process
void bbx_graph_process(
BbxGraph* handle,
const float* const* inputs,
float* const* outputs,
uint32_t num_channels,
uint32_t num_samples,
const float* params,
uint32_t num_params
);
Processes audio. Calls PluginDsp::apply_parameters() then PluginDsp::process().
Internal Types
BbxGraph
Opaque handle to the Rust DSP:
#![allow(unused)] fn main() { #[repr(C)] pub struct BbxGraph { _private: [u8; 0], } }
Never dereference - it's a type-erased pointer to GraphInner<T>.
BbxError
Error codes:
#![allow(unused)] fn main() { #[repr(C)] pub enum BbxError { Ok = 0, NullPointer = 1, InvalidParameter = 2, InvalidBufferSize = 3, GraphNotPrepared = 4, AllocationFailed = 5, } }
Macro Expansion
The macro expands to roughly:
#![allow(unused)] fn main() { type PluginGraphInner = GraphInner<MyPlugin>; #[no_mangle] pub extern "C" fn bbx_graph_create() -> *mut BbxGraph { let inner = Box::new(PluginGraphInner::new()); handle_from_graph(inner) } #[no_mangle] pub extern "C" fn bbx_graph_destroy(handle: *mut BbxGraph) { if !handle.is_null() { unsafe { drop(Box::from_raw(handle as *mut PluginGraphInner)); } } } // ... other functions }
Safety
The macro handles:
- Null pointer checks
- Parameter validation
- Safe type conversions
- Memory ownership transfer
Custom Function Names
Currently, function names are fixed (bbx_graph_*). For custom names, you would need to write the FFI layer manually.
Parameter Definitions
Utilities for defining plugin parameters and generating code.
Overview
bbx_plugin provides two approaches:
- JSON-based - Parse
parameters.json - Programmatic - Define as Rust const arrays
Both generate Rust constants and C headers.
JSON-Based Definitions
ParamsFile
Parse a JSON file:
#![allow(unused)] fn main() { use bbx_plugin::ParamsFile; let json = r#"{ "parameters": [ {"id": "GAIN", "name": "Gain", "type": "float", "min": -60.0, "max": 30.0, "defaultValue": 0.0}, {"id": "MONO", "name": "Mono", "type": "boolean", "defaultValue": false} ] }"#; let params = ParamsFile::from_json(json)?; }
JsonParamDef
Parameter definition from JSON:
#![allow(unused)] fn main() { pub struct JsonParamDef { pub id: String, pub name: String, pub param_type: String, // "float", "boolean", "choice" pub default_value: Option<serde_json::Value>, pub default_value_index: Option<usize>, pub min: Option<f64>, pub max: Option<f64>, pub unit: Option<String>, pub midpoint: Option<f64>, pub interval: Option<f64>, pub fraction_digits: Option<u32>, pub choices: Option<Vec<String>>, } }
Generating Code
#![allow(unused)] fn main() { use bbx_plugin::ParamsFile; let params = ParamsFile::from_json(json)?; // Generate Rust constants let rust_code = params.generate_rust_indices(); // pub const PARAM_GAIN: usize = 0; // pub const PARAM_MONO: usize = 1; // pub const PARAM_COUNT: usize = 2; // Generate C header let c_header = params.generate_c_header(); // #define PARAM_GAIN 0 // #define PARAM_MONO 1 // #define PARAM_COUNT 2 // static const char* PARAM_IDS[PARAM_COUNT] = { "GAIN", "MONO" }; }
The C header includes a PARAM_IDS array for dynamic iteration over parameters in C++.
Programmatic Definitions
ParamDef
Define parameters as const:
#![allow(unused)] fn main() { use bbx_plugin::{ParamDef, ParamType}; const PARAMETERS: &[ParamDef] = &[ ParamDef::float("GAIN", "Gain", -60.0, 30.0, 0.0), ParamDef::bool("MONO", "Mono", false), ParamDef::choice("MODE", "Mode", &["A", "B", "C"], 0), ]; }
Constructors
#![allow(unused)] fn main() { // Boolean ParamDef::bool("ID", "Name", default) // Float with range ParamDef::float("ID", "Name", min, max, default) // Choice (dropdown) ParamDef::choice("ID", "Name", &["Option1", "Option2"], default_index) }
Generating Code
#![allow(unused)] fn main() { use bbx_plugin::{generate_rust_indices_from_defs, generate_c_header_from_defs}; let rust_code = generate_rust_indices_from_defs(PARAMETERS); let c_header = generate_c_header_from_defs(PARAMETERS); }
Build Script Integration
// build.rs use std::fs; fn main() { // Read parameters.json let json = fs::read_to_string("parameters.json").unwrap(); let params = bbx_plugin::ParamsFile::from_json(&json).unwrap(); // Generate Rust code let rust_code = params.generate_rust_indices(); fs::write( format!("{}/params.rs", std::env::var("OUT_DIR").unwrap()), rust_code, ).unwrap(); // Generate C header let c_header = params.generate_c_header(); fs::write("include/bbx_params.h", c_header).unwrap(); println!("cargo:rerun-if-changed=parameters.json"); }
In lib.rs:
#![allow(unused)] fn main() { include!(concat!(env!("OUT_DIR"), "/params.rs")); }
See Also
- parameters.json Format - Full JSON schema
- Code Generation - Integration details
bbx_file
Audio file I/O for the bbx_audio workspace.
Overview
bbx_file provides:
- WAV file reading via
wavers - WAV file writing via
hound - Integration with bbx_dsp blocks
Installation
[dependencies]
bbx_file = "0.1"
bbx_dsp = "0.1"
Supported Formats
| Format | Read | Write |
|---|---|---|
| WAV | Yes | Yes |
Features
| Feature | Description |
|---|---|
| WAV Reader | Load WAV files |
| WAV Writer | Create WAV files |
Quick Example
Reading
#![allow(unused)] fn main() { use bbx_file::readers::wav::WavFileReader; let reader = WavFileReader::<f32>::from_path("audio.wav")?; println!("Sample rate: {}", reader.sample_rate()); println!("Channels: {}", reader.num_channels()); println!("Duration: {:.2}s", reader.duration_seconds()); let left_channel = reader.read_channel(0); }
Writing
#![allow(unused)] fn main() { use bbx_file::writers::wav::WavFileWriter; let mut writer = WavFileWriter::<f32>::new("output.wav", 44100.0, 2)?; writer.write_channel(0, &left_samples)?; writer.write_channel(1, &right_samples)?; writer.finalize()?; }
With bbx_dsp
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{FileInputBlock, GainBlock}, graph::GraphBuilder}; use bbx_file::readers::wav::WavFileReader; let reader = WavFileReader::<f32>::from_path("input.wav")?; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let file_in = builder.add(FileInputBlock::new(Box::new(reader))); let gain = builder.add(GainBlock::new(-6.0, None)); builder.connect(file_in, 0, gain, 0); let graph = builder.build(); }
WAV Reader
Load WAV files for processing with bbx_dsp.
Creating a Reader
#![allow(unused)] fn main() { use bbx_file::readers::wav::WavFileReader; let reader = WavFileReader::<f32>::from_path("audio.wav")?; }
File Information
#![allow(unused)] fn main() { use bbx_file::readers::wav::WavFileReader; use bbx_dsp::reader::Reader; let reader = WavFileReader::<f32>::from_path("audio.wav")?; // Sample rate in Hz let rate = reader.sample_rate(); // Number of channels let channels = reader.num_channels(); // Total samples per channel let samples = reader.num_samples(); // Duration let duration = reader.duration_seconds(); }
Reading Audio Data
Full Channel
#![allow(unused)] fn main() { let left = reader.read_channel(0); let right = reader.read_channel(1); }
Partial Read
#![allow(unused)] fn main() { // Read specific range let data = reader.read_range(0, 1000..2000); }
Reader Trait
WavFileReader implements the Reader trait from bbx_dsp:
#![allow(unused)] fn main() { pub trait Reader<S: Sample>: Send { fn sample_rate(&self) -> f64; fn num_channels(&self) -> usize; fn num_samples(&self) -> usize; fn duration_seconds(&self) -> f64; fn read_channel(&self, channel: usize) -> Vec<S>; } }
Usage with FileInputBlock
#![allow(unused)] fn main() { use bbx_dsp::{blocks::FileInputBlock, graph::GraphBuilder}; use bbx_file::readers::wav::WavFileReader; let reader = WavFileReader::<f32>::from_path("input.wav")?; let mut builder = GraphBuilder::<f32>::new( reader.sample_rate(), 512, reader.num_channels(), ); let file_input = builder.add(FileInputBlock::new(Box::new(reader))); }
Supported Formats
- PCM 8-bit unsigned
- PCM 16-bit signed
- PCM 24-bit signed
- PCM 32-bit signed
- IEEE Float 32-bit
- IEEE Float 64-bit
Error Handling
#![allow(unused)] fn main() { use bbx_file::readers::wav::WavFileReader; match WavFileReader::<f32>::from_path("audio.wav") { Ok(reader) => { // Use reader } Err(e) => { eprintln!("Failed to load: {}", e); } } }
Performance Notes
- Files are loaded entirely into memory
- For large files, consider streaming approaches
- Sample type conversion happens on load
WAV Writer
Create WAV files from processed audio.
Creating a Writer
#![allow(unused)] fn main() { use bbx_file::writers::wav::WavFileWriter; let writer = WavFileWriter::<f32>::new( "output.wav", // path 44100.0, // sample rate 2, // channels )?; }
Writing Audio Data
Per-Channel
#![allow(unused)] fn main() { use bbx_file::writers::wav::WavFileWriter; use bbx_dsp::writer::Writer; let mut writer = WavFileWriter::<f32>::new("output.wav", 44100.0, 2)?; writer.write_channel(0, &left_samples)?; writer.write_channel(1, &right_samples)?; writer.finalize()?; }
Interleaved
#![allow(unused)] fn main() { // Write interleaved samples [L, R, L, R, ...] writer.write_interleaved(&interleaved_samples)?; }
Finalization
Always call finalize() to:
- Flush buffered data
- Update WAV header with correct sizes
- Close the file
#![allow(unused)] fn main() { writer.finalize()?; }
Without finalization, the file may be corrupt or truncated.
Writer Trait
WavFileWriter implements the Writer trait from bbx_dsp:
#![allow(unused)] fn main() { pub trait Writer<S: Sample>: Send { fn sample_rate(&self) -> f64; fn num_channels(&self) -> usize; fn write_channel(&mut self, channel: usize, samples: &[S]) -> Result<()>; fn finalize(&mut self) -> Result<()>; } }
Usage with FileOutputBlock
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{FileOutputBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; use bbx_file::writers::wav::WavFileWriter; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Audio source let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // File output let writer = WavFileWriter::<f32>::new("output.wav", 44100.0, 2)?; let file_out = builder.add(FileOutputBlock::new(Box::new(writer))); builder.connect(osc, 0, file_out, 0); let mut graph = builder.build(); // Process audio for _ in 0..1000 { let mut left = vec![0.0f32; 512]; let mut right = vec![0.0f32; 512]; let mut outputs: [&mut [f32]; 2] = [&mut left, &mut right]; graph.process_buffers(&mut outputs); } // Finalize graph.finalize(); }
Output Format
Default output format:
- IEEE Float 32-bit
- Little-endian
- Standard RIFF/WAVE header
Error Handling
#![allow(unused)] fn main() { use bbx_file::writers::wav::WavFileWriter; let writer = WavFileWriter::<f32>::new("output.wav", 44100.0, 2); match writer { Ok(w) => { // Use writer } Err(e) => { eprintln!("Failed to create writer: {}", e); } } }
Non-Blocking I/O
FileOutputBlock uses non-blocking I/O internally to avoid blocking the audio thread. Actual disk writes happen on a background thread.
bbx_midi
MIDI message parsing and streaming utilities.
Overview
bbx_midi provides:
- MIDI message parsing from raw bytes
- Message type helpers (note, velocity, CC, etc.)
- Lock-free MIDI buffer for thread-safe communication
- Input streaming via midir
- FFI-compatible types
Installation
[dependencies]
bbx_midi = "0.1"
Features
| Feature | Description |
|---|---|
| MIDI Messages | Message parsing and types |
| Lock-Free Buffer | Thread-safe MIDI transfer |
Quick Example
#![allow(unused)] fn main() { use bbx_midi::{MidiMessage, MidiMessageStatus}; // Parse raw MIDI bytes let msg = MidiMessage::new([0x90, 60, 100]); // Note On, C4, velocity 100 if msg.get_status() == MidiMessageStatus::NoteOn { println!("Note: {}", msg.get_note().unwrap()); println!("Velocity: {}", msg.get_velocity().unwrap()); println!("Frequency: {:.2} Hz", msg.get_note_frequency().unwrap()); } }
Lock-Free Buffer
For thread-safe MIDI communication between MIDI and audio threads:
#![allow(unused)] fn main() { use bbx_midi::{midi_buffer, MidiMessage}; // Create producer/consumer pair let (mut producer, mut consumer) = midi_buffer(64); // MIDI thread: push messages let msg = MidiMessage::new([0x90, 60, 100]); producer.try_send(msg); // Audio thread: pop messages (realtime-safe) while let Some(msg) = consumer.try_pop() { // Process MIDI } }
Message Types
| Status | Description |
|---|---|
| NoteOn | Key pressed |
| NoteOff | Key released |
| ControlChange | CC (knobs, pedals) |
| PitchWheel | Pitch bend |
| ProgramChange | Preset change |
| PolyphonicAftertouch | Per-note pressure |
| ChannelAftertouch | Channel pressure |
FFI Compatibility
MIDI types use #[repr(C)] for C interop:
typedef struct {
uint8_t data[3];
} MidiMessage;
MIDI Messages
Parse and work with MIDI messages.
MidiMessage
#![allow(unused)] fn main() { use bbx_midi::MidiMessage; // Create from raw bytes let msg = MidiMessage::new([0x90, 60, 100]); // Or from parts let msg = MidiMessage::note_on(0, 60, 100); // Channel 0, Note 60, Velocity 100 }
MidiMessageStatus
#![allow(unused)] fn main() { use bbx_midi::MidiMessageStatus; pub enum MidiMessageStatus { NoteOff, NoteOn, PolyphonicAftertouch, ControlChange, ProgramChange, ChannelAftertouch, PitchWheel, Unknown, } }
Constructors
#![allow(unused)] fn main() { use bbx_midi::MidiMessage; // Note events let note_on = MidiMessage::note_on(channel, note, velocity); let note_off = MidiMessage::note_off(channel, note, velocity); // Control change let cc = MidiMessage::control_change(channel, controller, value); // Pitch bend let bend = MidiMessage::pitch_bend(channel, value); // value: 0-16383 // Program change let program = MidiMessage::program_change(channel, program); }
Accessors
Status and Channel
#![allow(unused)] fn main() { let msg = MidiMessage::note_on(5, 60, 100); let status = msg.get_status(); // MidiMessageStatus::NoteOn let channel = msg.get_channel(); // 5 }
Note Events
#![allow(unused)] fn main() { let msg = MidiMessage::note_on(0, 60, 100); let note = msg.get_note().unwrap(); // 60 let velocity = msg.get_velocity().unwrap(); // 100 let frequency = msg.get_note_frequency().unwrap(); // 261.63 }
Control Change
#![allow(unused)] fn main() { let msg = MidiMessage::control_change(0, 1, 64); let controller = msg.get_controller().unwrap(); // 1 (mod wheel) let value = msg.get_value().unwrap(); // 64 }
Pitch Bend
#![allow(unused)] fn main() { let msg = MidiMessage::pitch_bend(0, 8192); // Center let bend = msg.get_pitch_bend().unwrap(); // 8192 // bend range: 0 (full down) - 8192 (center) - 16383 (full up) }
Note Frequency
Convert MIDI note numbers to Hz:
#![allow(unused)] fn main() { use bbx_midi::MidiMessage; let msg = MidiMessage::note_on(0, 69, 100); // A4 let freq = msg.get_note_frequency().unwrap(); // 440.0 let msg = MidiMessage::note_on(0, 60, 100); // Middle C let freq = msg.get_note_frequency().unwrap(); // 261.63 }
Common Controller Numbers
#![allow(unused)] fn main() { const CC_MOD_WHEEL: u8 = 1; const CC_BREATH: u8 = 2; const CC_VOLUME: u8 = 7; const CC_BALANCE: u8 = 8; const CC_PAN: u8 = 10; const CC_EXPRESSION: u8 = 11; const CC_SUSTAIN: u8 = 64; const CC_PORTAMENTO: u8 = 65; const CC_SOSTENUTO: u8 = 66; const CC_SOFT_PEDAL: u8 = 67; const CC_ALL_SOUND_OFF: u8 = 120; const CC_ALL_NOTES_OFF: u8 = 123; }
Pattern Matching
#![allow(unused)] fn main() { use bbx_midi::{MidiMessage, MidiMessageStatus}; fn handle_midi(msg: &MidiMessage) { match msg.get_status() { MidiMessageStatus::NoteOn => { if msg.get_velocity().unwrap() > 0 { // Note on with velocity } else { // Note on with velocity 0 = note off } } MidiMessageStatus::NoteOff => { // Note off } MidiMessageStatus::ControlChange => { // CC message } _ => {} } } }
Lock-Free MIDI Buffer
A lock-free SPSC ring buffer for thread-safe MIDI communication between MIDI input and audio threads.
Overview
The midi_buffer function creates a producer/consumer pair for real-time MIDI message transfer:
MidiBufferProducer- Used in the MIDI input thread to push messagesMidiBufferConsumer- Used in the audio thread to pop messages
All consumer operations are lock-free and allocation-free, making them safe for real-time audio callbacks.
Creating a Buffer
#![allow(unused)] fn main() { use bbx_midi::midi_buffer; // Create a buffer with capacity for 64 messages let (mut producer, mut consumer) = midi_buffer(64); }
Typical capacity values are 64-256 messages.
Producer (MIDI Thread)
#![allow(unused)] fn main() { use bbx_midi::{midi_buffer, MidiMessage}; let (mut producer, _consumer) = midi_buffer(64); // Send a MIDI message let msg = MidiMessage::new([0x90, 60, 100]); if producer.try_send(msg) { // Message sent successfully } else { // Buffer is full, message dropped } // Check if buffer is full if producer.is_full() { // Handle overflow } }
Consumer (Audio Thread)
#![allow(unused)] fn main() { use bbx_midi::midi_buffer; let (_producer, mut consumer) = midi_buffer(64); // Pop messages one at a time (realtime-safe) while let Some(msg) = consumer.try_pop() { // Process MIDI message } // Check if buffer is empty if consumer.is_empty() { // No messages pending } }
Draining Messages
For batch processing, drain all messages into a pre-allocated buffer:
#![allow(unused)] fn main() { use bbx_midi::{midi_buffer, MidiMessage}; let (_producer, mut consumer) = midi_buffer(64); let mut messages = Vec::with_capacity(64); let count = consumer.drain_into(&mut messages); println!("Received {} messages", count); }
Note: drain_into uses Vec::push, which may allocate. For strict real-time safety, use try_pop in a loop instead.
Usage Pattern
Typical MIDI synthesizer pattern with separate threads:
use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use bbx_midi::{midi_buffer, MidiMessage, MidiBufferProducer, MidiBufferConsumer}; fn main() { let (producer, consumer) = midi_buffer(256); // MIDI input thread (via midir) let _midi_connection = setup_midi_input(producer); // Audio processing (via rodio or cpal) let synth = Synth::new(consumer); // ... play synth } // MIDI callback pushes to producer fn setup_midi_input(mut producer: MidiBufferProducer) { // In midir callback: // producer.try_send(MidiMessage::from(bytes)); } struct Synth { consumer: MidiBufferConsumer, } impl Synth { fn new(consumer: MidiBufferConsumer) -> Self { Self { consumer } } fn process_audio(&mut self, output: &mut [f32]) { // Process pending MIDI (realtime-safe) while let Some(msg) = self.consumer.try_pop() { self.handle_midi(&msg); } // Generate audio... } fn handle_midi(&mut self, msg: &MidiMessage) { // Handle note on/off, CC, etc. } }
Thread Safety
MidiBufferProducerandMidiBufferConsumerareSendbut notSync- Each should be owned by exactly one thread
- The underlying SPSC ring buffer handles synchronization
- All consumer operations are wait-free
See Also
- SPSC Ring Buffer - The underlying lock-free queue
- Building a MIDI Synthesizer - Complete example
Generators
Generator blocks create audio signals from nothing.
Available Generators
| Block | Description |
|---|---|
| OscillatorBlock | Waveform generator |
Characteristics
Generators have:
- 0 inputs - They create signal, not process it
- 1+ outputs - Audio signal output
- No modulation outputs - They produce audio, not control signals
Usage Pattern
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{GainBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Add a generator let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Connect to effects or output let gain = builder.add(GainBlock::new(-6.0, None)); builder.connect(osc, 0, gain, 0); }
Polyphony
Create multiple generators for polyphonic sounds:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{GainBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Three oscillators for a chord let c4 = builder.add(OscillatorBlock::new(261.63, Waveform::Sine, None)); let e4 = builder.add(OscillatorBlock::new(329.63, Waveform::Sine, None)); let g4 = builder.add(OscillatorBlock::new(392.00, Waveform::Sine, None)); // Mix them let mixer = builder.add(GainBlock::new(-9.0, None)); builder.connect(c4, 0, mixer, 0); builder.connect(e4, 0, mixer, 0); builder.connect(g4, 0, mixer, 0); }
Future Generators
Potential additions:
- SamplerBlock - Sample playback
- WavetableBlock - Wavetable synthesis
- NoiseBlock - Dedicated noise generator
- GranularBlock - Granular synthesis
OscillatorBlock
A waveform generator supporting multiple wave shapes with band-limited output.
Overview
OscillatorBlock generates audio waveforms at a specified frequency with optional frequency modulation via the modulate() method.
Mathematical Foundation
Phase Accumulator
At the heart of every oscillator is a phase accumulator—a counter that tracks the current position within a waveform's cycle. The phase $\phi$ advances by a fixed increment each sample:
$$ \phi[n] = \phi[n-1] + \Delta\phi $$
where the phase increment is determined by the desired frequency and sample rate:
$$ \Delta\phi = \frac{2\pi f}{f_s} $$
- $f$ is the oscillator frequency in Hz
- $f_s$ is the sample rate (e.g., 44100 Hz)
- $2\pi$ represents one complete cycle (radians)
The phase wraps around at $2\pi$ to stay within one period: $\phi = \phi \mod 2\pi$
Example: A 440 Hz oscillator at 44100 Hz sample rate advances by $\frac{2\pi \times 440}{44100} \approx 0.0627$ radians per sample, completing one cycle in approximately 100 samples.
Waveform Equations
Each waveform shape maps the phase $\phi \in [0, 2\pi)$ to an output amplitude $y \in [-1, 1]$. These "naive" formulas produce the correct shape but suffer from aliasing at higher frequencies.
| Waveform | Formula | Output Range |
|---|---|---|
| Sine | $y = \sin(\phi)$ | $[-1, 1]$ |
| Sawtooth | $y = \frac{\phi}{\pi} - 1$ | $[-1, 1]$ |
| Square | $y = \text{sign}(\sin(\phi))$ | ${-1, 1}$ |
| Triangle | $y = \frac{2}{\pi}|\phi - \pi| - 1$ | $[-1, 1]$ |
| Pulse | $y = \begin{cases} 1 & \text{if } \phi < 2\pi d \ -1 & \text{otherwise} \end{cases}$ | ${-1, 1}$ |
where $d$ is the duty cycle (default 0.5 for 50% duty).
Harmonic Content
Different waveforms contain different harmonics, giving them distinct timbres:
| Waveform | Harmonics Present | Amplitude Falloff |
|---|---|---|
| Sine | Fundamental only | — |
| Square | Odd (1, 3, 5, 7...) | $\frac{1}{n}$ |
| Sawtooth | All (1, 2, 3, 4...) | $\frac{1}{n}$ |
| Triangle | Odd (1, 3, 5, 7...) | $\frac{1}{n^2}$ |
| Pulse | All | Varies with duty cycle |
The Fourier series for a sawtooth wave illustrates why it sounds bright:
$$ y(t) = -\frac{2}{\pi} \sum_{n=1}^{\infty} \frac{(-1)^n}{n} \sin(2\pi n f t) $$
The Aliasing Problem
The Nyquist theorem states that we can only accurately represent frequencies below $\frac{f_s}{2}$ (the Nyquist frequency). For a 44.1 kHz sample rate, this limit is 22.05 kHz.
Waveforms with sharp discontinuities (square, sawtooth, pulse) theoretically contain infinite harmonics. When the oscillator frequency $f$ is high enough that harmonics exceed the Nyquist frequency, those harmonics "fold back" into the audible range as aliasing—harsh, unmusical artifacts.
Example: A 10 kHz sawtooth has harmonics at 10k, 20k, 30k, 40k... Hz. At 44.1 kHz sample rate:
- The 30 kHz harmonic aliases to $44.1 - 30 = 14.1$ kHz
- The 40 kHz harmonic aliases to $44.1 - 40 = 4.1$ kHz
Band-Limited Synthesis with PolyBLEP
PolyBLEP (Polynomial Band-Limited Step) is an efficient technique that reduces aliasing by smoothing discontinuities. Rather than computing a band-limited waveform from scratch, it starts with the naive waveform and applies a correction near transitions.
The key insight: aliasing comes from step discontinuities (sudden jumps in value). If we can smooth these jumps, we reduce the high-frequency content that causes aliasing.
The PolyBLEP Correction
For a discontinuity at normalized phase $t = 0$ (where $t \in [0, 1]$), the PolyBLEP correction is:
$$ \text{polyBLEP}(t, \Delta t) = \begin{cases} 2t' - t'^2 - 1 & \text{if } t < \Delta t \ t'^2 + 2t' + 1 & \text{if } t > 1 - \Delta t \ 0 & \text{otherwise} \end{cases} $$
where $t' = t / \Delta t$ near the start of the cycle, or $t' = (t - 1) / \Delta t$ near the end, and $\Delta t$ is the normalized phase increment.
The correction is applied by subtracting from the naive waveform:
$$ y_{\text{bandlimited}} = y_{\text{naive}} - \text{polyBLEP}(t, \Delta t) $$
PolyBLAMP for Triangle Waves
Triangle waves have slope discontinuities (sudden changes in direction) rather than step discontinuities. PolyBLAMP (Band-Limited rAMP) is the integrated form of PolyBLEP, designed for these cases:
$$ \text{polyBLAMP}(t, \Delta t) = \begin{cases} (t'^2 - \frac{t'^3}{3} - t') \cdot \Delta t & \text{if } t < \Delta t \ (\frac{t'^3}{3} + t'^2 + t') \cdot \Delta t & \text{if } t > 1 - \Delta t \ 0 & \text{otherwise} \end{cases} $$
The triangle wave correction uses a scaling factor of 8:
$$ y_{\text{triangle}} = y_{\text{naive}} + 8 \cdot \text{polyBLAMP}(t, \Delta t) - 8 \cdot \text{polyBLAMP}(t + 0.5, \Delta t) $$
Pitch Modulation
Pitch offset is applied using the equal-tempered tuning formula:
$$ f_{\text{actual}} = f_{\text{base}} \cdot 2^{\frac{s}{12}} $$
where $s$ is the offset in semitones. This formula means:
- +12 semitones doubles the frequency (one octave up)
- -12 semitones halves the frequency (one octave down)
- +1 semitone multiplies by $2^{1/12} \approx 1.0595$
Creating an Oscillator
#![allow(unused)] fn main() { use bbx_dsp::{blocks::OscillatorBlock, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Parameters: frequency (Hz), waveform, optional seed for noise let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); }
The third parameter is an optional seed (Option<u64>) for deterministic random number generation, used by the Noise waveform.
Waveforms
#![allow(unused)] fn main() { use bbx_dsp::waveform::Waveform; Waveform::Sine // Pure sine wave Waveform::Square // Square wave (50% duty) Waveform::Saw // Sawtooth (ramp up) Waveform::Triangle // Triangle wave Waveform::Pulse // Pulse with variable width Waveform::Noise // White noise }
Waveform Characteristics
| Waveform | Harmonics | Character |
|---|---|---|
| Sine | Fundamental only | Pure, clean |
| Square | Odd harmonics | Hollow, woody |
| Saw | All harmonics | Bright, buzzy |
| Triangle | Odd (weak) | Soft, flute-like |
| Pulse | Variable | Nasal, reedy |
| Noise | All frequencies | Airy, percussive |
Parameters
| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
| frequency | f64 | 0.01 - 20000 Hz | 440 | Base frequency |
| pitch_offset | f64 | -24 to +24 semitones | 0 | Pitch offset from base |
Both parameters can be modulated using the modulate() method.
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0 | Output | Audio signal |
Frequency Modulation
Use an LFO for vibrato via the modulate() method:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{LfoBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Create LFO for vibrato (5 Hz, moderate depth) let lfo = builder.add(LfoBlock::new(5.0, 0.3, Waveform::Sine, None)); // Create oscillator let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Connect LFO to modulate frequency builder.modulate(lfo, osc, "frequency"); }
You can also modulate the pitch_offset parameter:
#![allow(unused)] fn main() { // Modulate pitch offset instead of frequency builder.modulate(lfo, osc, "pitch_offset"); }
Usage Examples
Basic Tone
#![allow(unused)] fn main() { let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); }
Detuned Oscillators
#![allow(unused)] fn main() { let osc1 = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); let osc2 = builder.add(OscillatorBlock::new(440.0 * 1.005, Waveform::Saw, None)); // +8.6 cents let osc3 = builder.add(OscillatorBlock::new(440.0 / 1.005, Waveform::Saw, None)); // -8.6 cents }
Sub-Oscillator
#![allow(unused)] fn main() { let main = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); let sub = builder.add(OscillatorBlock::new(220.0, Waveform::Sine, None)); // One octave down }
Deterministic Noise
For reproducible noise output:
#![allow(unused)] fn main() { // Same seed produces same noise pattern let noise1 = builder.add(OscillatorBlock::new(0.0, Waveform::Noise, Some(12345))); let noise2 = builder.add(OscillatorBlock::new(0.0, Waveform::Noise, Some(12345))); // Same as noise1 }
Deterministic Noise
For reproducible noise output:
#![allow(unused)] fn main() { // Same seed produces same noise pattern let noise1 = builder.add_oscillator(0.0, Waveform::Noise, Some(12345)); let noise2 = builder.add_oscillator(0.0, Waveform::Noise, Some(12345)); // Same as noise1 }
Implementation Notes
- Phase accumulator runs continuously
- Band-limited output using PolyBLEP/PolyBLAMP anti-aliasing:
- Saw, Square, Pulse: PolyBLEP corrects step discontinuities
- Triangle: PolyBLAMP corrects slope discontinuities
- Sine: Naturally band-limited (no correction needed)
- Noise: No discontinuities (no correction needed)
- Noise is sample-and-hold (per-sample random)
- Frequency is ignored for Noise waveform
Further Reading
- Välimäki, V., et al. (2010). "Oscillator and Filter Algorithms for Virtual Analog Synthesis." Computer Music Journal, 30(2).
- Smith, J.O. (2010). Physical Audio Signal Processing. online
- Pirkle, W. (2019). Designing Audio Effect Plugins in C++, Chapter 8: Oscillators.
Effectors
Effector blocks process and transform audio signals.
Available Effectors
| Block | Description |
|---|---|
| GainBlock | Level control in dB |
| VcaBlock | Voltage controlled amplifier |
| PannerBlock | Stereo, surround (VBAP), and ambisonic panning |
| OverdriveBlock | Soft-clipping distortion |
| DcBlockerBlock | DC offset removal |
| ChannelRouterBlock | Simple stereo channel routing |
| ChannelSplitterBlock | Split multi-channel to mono outputs |
| ChannelMergerBlock | Merge mono inputs to multi-channel |
| MatrixMixerBlock | NxM mixing matrix |
| MixerBlock | Channel-wise audio mixer |
| AmbisonicDecoderBlock | Ambisonics B-format decoder |
| BinauralDecoderBlock | B-format to stereo binaural |
| LowPassFilterBlock | SVF low-pass filter |
Characteristics
Effectors have:
- 1+ inputs - Audio to process
- 1+ outputs - Processed audio
- No modulation outputs - They produce audio, not control
Effect Chain Order
Order matters for sound quality:
Recommended order:
Source -> Gain (input level)
-> Distortion
-> DC Blocker
-> Filter
-> Panning
-> Gain (output level)
Usage Pattern
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{DcBlockerBlock, GainBlock, OscillatorBlock, OverdriveBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Source let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); // Effect chain let drive = builder.add(OverdriveBlock::new(3.0, 1.0, 0.8, 44100.0)); let dc = builder.add(DcBlockerBlock::new(true)); let gain = builder.add(GainBlock::new(-6.0, None)); // Connect in series builder.connect(osc, 0, drive, 0); builder.connect(drive, 0, dc, 0); builder.connect(dc, 0, gain, 0); }
Parallel Processing
Split signal to multiple effects:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{GainBlock, OscillatorBlock, OverdriveBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let source = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); // Dry path let dry_gain = builder.add(GainBlock::new(-6.0, None)); // Wet path (distorted) let wet_drive = builder.add(OverdriveBlock::new(5.0, 1.0, 0.5, 44100.0)); // Connect source to both builder.connect(source, 0, dry_gain, 0); builder.connect(source, 0, wet_drive, 0); // Mix back together let mixer = builder.add(GainBlock::new(-3.0, None)); builder.connect(dry_gain, 0, mixer, 0); builder.connect(wet_drive, 0, mixer, 0); }
GainBlock
Amplitude control with decibel input and smooth parameter changes.
Overview
GainBlock applies amplitude scaling to audio signals. The gain level is specified in decibels (dB), the standard unit for audio levels, and changes are smoothed to prevent clicks and pops.
Mathematical Foundation
Decibels (dB)
The decibel is a logarithmic unit that measures ratios. In audio, we use decibels to express voltage (amplitude) ratios:
$$ \text{dB} = 20 \log_{10}\left(\frac{V_{out}}{V_{in}}\right) $$
Rearranging to find the linear gain from a dB value:
$$ G_{linear} = 10^{\frac{G_{dB}}{20}} $$
Why Logarithmic?
Human hearing is approximately logarithmic in its perception of loudness. A 10 dB increase sounds roughly "twice as loud" regardless of the starting level. This makes dB a perceptually meaningful unit:
| dB Change | Perceived Effect | Linear Multiplier |
|---|---|---|
| +20 dB | 4× louder | 10.0 |
| +10 dB | 2× louder | 3.16 |
| +6 dB | Noticeably louder | 2.0 |
| +3 dB | Just noticeably louder | 1.41 |
| 0 dB | No change | 1.0 |
| -3 dB | Just noticeably quieter | 0.71 |
| -6 dB | Noticeably quieter | 0.5 |
| -10 dB | Half as loud | 0.316 |
| -20 dB | Quarter as loud | 0.1 |
Amplitude vs Power
Audio engineers use two conventions:
- Amplitude/Voltage: $\text{dB} = 20 \log_{10}(ratio)$
- Power/Intensity: $\text{dB} = 10 \log_{10}(ratio)$
Since power is proportional to amplitude squared ($P \propto V^2$): $$ 20 \log_{10}(V) = 10 \log_{10}(V^2) = 10 \log_{10}(P) $$
This block uses the amplitude convention.
Common Reference Points
| dB | Linear | Relationship |
|---|---|---|
| +30 dB | 31.6 | Maximum boost in this block |
| +6 dB | 2.0 | Double amplitude |
| +3 dB | 1.414 | Double power |
| 0 dB | 1.0 | Unity gain (no change) |
| -3 dB | 0.707 | Half power |
| -6 dB | 0.5 | Half amplitude |
| -20 dB | 0.1 | One-tenth amplitude |
| -40 dB | 0.01 | One-hundredth amplitude |
| -60 dB | 0.001 | Roughly noise floor |
| -80 dB | 0.0001 | Minimum in this block (near silence) |
Parameter Smoothing
Abrupt gain changes cause clicks—audible discontinuities at the transition point. The block uses linear interpolation (smoothing) to ramp between gain values over several samples:
$$ G[n] = G[n-1] + \frac{G_{target} - G[n-1]}{\tau} $$
where $\tau$ is the smoothing time constant.
Creating a Gain Block
#![allow(unused)] fn main() { use bbx_dsp::{blocks::GainBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Level in dB, optional base gain multiplier let gain = builder.add(GainBlock::new(-6.0, None)); // With base gain multiplier (applied statically) let gain = builder.add(GainBlock::new(0.0, Some(0.5))); // 0 dB + 50% = -6 dB effective }
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0 | Input | Audio input |
| 0 | Output | Scaled audio output |
Parameters
| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
| level_db | f64 | -80.0 to +30.0 dB | 0.0 | Gain level in decibels |
| base_gain | f64 | 0.0 to 1.0+ | 1.0 | Additional linear multiplier |
Level Values
| dB | Linear | Effect |
|---|---|---|
| +12 | 4.0 | 4x louder |
| +6 | 2.0 | 2x louder |
| +3 | 1.41 | ~1.4x louder |
| 0 | 1.0 | No change |
| -3 | 0.71 | ~0.7x amplitude |
| -6 | 0.5 | Half amplitude |
| -12 | 0.25 | Quarter amplitude |
| -20 | 0.1 | 10% amplitude |
| -80 | ~0.0 | Near silence |
Usage Examples
Volume Control
#![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 source = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let volume = builder.add(GainBlock::new(-12.0, None)); // -12 dB builder.connect(source, 0, volume, 0); }
Unity Gain (Passthrough)
#![allow(unused)] fn main() { use bbx_dsp::blocks::GainBlock; // Unity gain - useful as a mixing point or modulation target let gain = GainBlock::<f32>::unity(); }
Gain Staging
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{GainBlock, OscillatorBlock, OverdriveBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let source = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Input gain before processing let input_gain = builder.add(GainBlock::new(6.0, None)); // Processing let effect = builder.add(OverdriveBlock::new(3.0, 1.0, 0.5, 44100.0)); // Output gain after processing let output_gain = builder.add(GainBlock::new(-6.0, None)); builder.connect(source, 0, input_gain, 0); builder.connect(input_gain, 0, effect, 0); builder.connect(effect, 0, output_gain, 0); }
Modulated Gain (Tremolo)
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{GainBlock, LfoBlock, 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 lfo = builder.add(LfoBlock::new(4.0, 0.5, Waveform::Sine, None)); // 4 Hz tremolo let gain = builder.add(GainBlock::new(0.0, None)); builder.connect(osc, 0, gain, 0); builder.modulate(lfo, gain, "level_db"); }
Base Gain for Fixed Scaling
#![allow(unused)] fn main() { use bbx_dsp::{blocks::GainBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Base gain of 0.5 applied statically (multiplied with dB gain) let gain = builder.add(GainBlock::new(0.0, Some(0.5))); // Effective: 0 dB * 0.5 = -6.02 dB }
Implementation Notes
- dB range: -80 dB to +30 dB (clamped)
- Click-free transitions via linear smoothing
- Multi-channel support (applies same gain to all channels)
- SIMD-optimized when not smoothing
- Base gain multiplied with dB-derived gain
Further Reading
- Sethares, W. (2005). Tuning, Timbre, Spectrum, Scale, Appendix: Decibels. Springer.
- Self, D. (2009). Audio Power Amplifier Design Handbook, Chapter 1: Gain Control. Focal Press.
- Pohlmann, K. (2010). Principles of Digital Audio, Chapter 2: Sound and Hearing. McGraw-Hill.
VcaBlock
Voltage Controlled Amplifier - multiplies audio by a control signal.
Overview
VcaBlock multiplies an audio signal by a control signal sample-by-sample. This is the standard way to apply envelope control to audio in synthesizers.
Creating a VCA Block
#![allow(unused)] fn main() { use bbx_dsp::{blocks::VcaBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let vca = builder.add(VcaBlock::new()); }
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0 | Input | Audio signal |
| 1 | Input | Control signal (0.0 to 1.0) |
| 0 | Output | Modulated audio |
Parameters
VcaBlock has no parameters - it simply multiplies its two inputs together.
Usage Examples
Envelope-Controlled Amplitude
The most common use case: apply an ADSR envelope to an oscillator.
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{EnvelopeBlock, OscillatorBlock, VcaBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Signal chain: Oscillator → VCA ← Envelope let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); let env = builder.add(EnvelopeBlock::new(0.01, 0.1, 0.7, 0.3)); let vca = builder.add(VcaBlock::new()); // Audio to VCA input 0 builder.connect(osc, 0, vca, 0); // Envelope to VCA input 1 (control) builder.connect(env, 0, vca, 1); }
LFO Tremolo
Use an LFO for amplitude modulation (tremolo effect):
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{LfoBlock, OscillatorBlock, VcaBlock}, 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 lfo = builder.add(LfoBlock::new(6.0, 0.5, Waveform::Sine, None)); // 6 Hz, 50% depth let vca = builder.add(VcaBlock::new()); builder.connect(osc, 0, vca, 0); builder.connect(lfo, 0, vca, 1); }
Subtractive Synth Voice
Complete synthesizer voice with oscillator, envelope, filter, and VCA:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{EnvelopeBlock, GainBlock, LowPassFilterBlock, OscillatorBlock, VcaBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Sound source let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); // Amplitude envelope let env = builder.add(EnvelopeBlock::new(0.01, 0.1, 0.7, 0.3)); // VCA for envelope control let vca = builder.add(VcaBlock::new()); // Filter and output gain let filter = builder.add(LowPassFilterBlock::new(2000.0, 1.5)); let gain = builder.add(GainBlock::new(-6.0, None)); // Connect: Osc → VCA → Filter → Gain builder .connect(osc, 0, vca, 0) .connect(env, 0, vca, 1) .connect(vca, 0, filter, 0) .connect(filter, 0, gain, 0); }
Behavior
- When control input is missing, defaults to 1.0 (unity gain)
- When audio input is missing, outputs silence
- Output is sample-by-sample multiplication:
output[i] = audio[i] * control[i]
VCA vs GainBlock
| Feature | VcaBlock | GainBlock |
|---|---|---|
| Control | Sample-by-sample from input | Fixed dB parameter |
| Use case | Envelope/LFO modulation | Static level control |
| Inputs | 2 (audio + control) | 1 (audio only) |
Use VCA for dynamic amplitude control. Use Gain for static level adjustments.
Implementation Notes
- No internal state or smoothing
- Zero-latency processing
- Handles any buffer size
PannerBlock
Multi-format spatial panning supporting stereo, surround, and ambisonics.
Overview
PannerBlock positions a mono signal in 2D or 3D space using three different algorithms:
- Stereo: Constant-power panning between left and right
- Surround: VBAP (Vector Base Amplitude Panning) for 5.1/7.1 layouts
- Ambisonic: Spherical harmonic encoding for immersive audio
Mathematical Foundation
Coordinate System
For surround and ambisonic modes, positions are specified in spherical coordinates:
- Azimuth $\theta$: Horizontal angle from front
- $0°$ = front
- $90°$ = left
- $-90°$ = right
- $±180°$ = rear
- Elevation $\phi$: Vertical angle from horizon
- $0°$ = horizon
- $90°$ = directly above
- $-90°$ = directly below
Stereo: Constant-Power Pan Law
Simple left/right panning uses a constant-power pan law based on sine and cosine functions. This preserves perceived loudness as the sound moves across the stereo field.
For a pan position $p \in [-100, 100]$:
-
Normalize to $[0, 1]$: $$ t = \frac{p + 100}{200} $$
-
Convert to angle: $$ \alpha = t \cdot \frac{\pi}{2} $$
-
Calculate gains: $$ g_L = \cos(\alpha), \quad g_R = \sin(\alpha) $$
Why Constant-Power?
The key insight is that perceived loudness relates to power, not amplitude. When a sound is centered, it plays equally from both speakers, and the listener perceives both contributions combined.
Linear panning (using $g_L = 1-t$ and $g_R = t$) causes a loudness dip in the center because: $$ g_L^2 + g_R^2 = (1-t)^2 + t^2 \neq 1 \quad \text{(varies from 0.5 to 1)} $$
Constant-power panning maintains consistent loudness because: $$ g_L^2 + g_R^2 = \cos^2(\alpha) + \sin^2(\alpha) = 1 \quad \text{(always)} $$
| Position | $t$ | $\alpha$ | $g_L$ | $g_R$ | $g_L^2 + g_R^2$ |
|---|---|---|---|---|---|
| Full left (-100) | 0.0 | 0° | 1.0 | 0.0 | 1.0 |
| Center (0) | 0.5 | 45° | 0.707 | 0.707 | 1.0 |
| Full right (+100) | 1.0 | 90° | 0.0 | 1.0 | 1.0 |
Surround: Vector Base Amplitude Panning (VBAP)
VBAP generalizes panning to arbitrary speaker layouts by computing gains based on angular distance from each speaker.
Speaker Positions
Standard speaker layouts define specific azimuths:
5.1 (ITU-R BS.775-1):
| Channel | Name | Azimuth |
|---|---|---|
| 0 | L (Left) | 30° |
| 1 | R (Right) | -30° |
| 2 | C (Center) | 0° |
| 3 | LFE | — (omnidirectional) |
| 4 | Ls (Left Surround) | 110° |
| 5 | Rs (Right Surround) | -110° |
7.1 (ITU-R BS.2051):
| Channel | Name | Azimuth |
|---|---|---|
| 0 | L (Left) | 30° |
| 1 | R (Right) | -30° |
| 2 | C (Center) | 0° |
| 3 | LFE | — |
| 4 | Ls (Left Side) | 90° |
| 5 | Rs (Right Side) | -90° |
| 6 | Lrs (Left Rear) | 150° |
| 7 | Rrs (Right Rear) | -150° |
Gain Calculation
For a source at azimuth $\theta_s$ and speaker $i$ at azimuth $\theta_i$:
-
Calculate angular difference (handling wrap-around): $$ \Delta\theta_i = \min(|\theta_s - \theta_i|, 2\pi - |\theta_s - \theta_i|) $$
-
Apply gain based on a spread angle $\sigma$ (typically $\frac{\pi}{2}$): $$ g_i = \begin{cases} \cos\left(\frac{\sigma - \Delta\theta_i}{\sigma} \cdot \frac{\pi}{2}\right) & \text{if } \Delta\theta_i < \sigma \ 0 & \text{otherwise} \end{cases} $$
-
Normalize for constant energy: $$ g_i' = \frac{g_i}{\sqrt{\sum_j g_j^2}} $$
This ensures that the total power remains constant regardless of source position.
Ambisonics: Spherical Harmonic Encoding
Ambisonics represents sound fields using spherical harmonics—mathematical functions that describe how sound varies with direction. Unlike channel-based formats, ambisonics is speaker-independent: the encoded signal can be decoded to any speaker layout.
Spherical Harmonics
Spherical harmonics $Y_l^m(\theta, \phi)$ form an orthonormal basis for functions on the sphere, where:
- $l$ is the order (0, 1, 2, 3...)
- $m$ is the degree ($-l \leq m \leq l$)
Higher orders capture finer spatial detail but require more channels:
- Order 0: 1 channel (omnidirectional)
- Order 1: 4 channels (directional)
- Order 2: 9 channels (improved localization)
- Order 3: 16 channels (high spatial resolution)
The channel count for order $L$ is $(L+1)^2$.
SN3D Normalization and ACN Ordering
This implementation uses:
- SN3D (Semi-Normalized 3D): Schmidt semi-normalization for consistent gain
- ACN (Ambisonic Channel Number): Standard channel ordering
Encoding Coefficients
For a source at azimuth $\theta$ and elevation $\phi$:
Order 0 (omnidirectional): $$ Y_0^0 = 1 \quad \text{(W channel)} $$
Order 1 (first-order, directional): $$ \begin{aligned} Y_1^{-1} &= \cos\phi \sin\theta \quad \text{(Y channel)} \ Y_1^0 &= \sin\phi \quad \text{(Z channel)} \ Y_1^1 &= \cos\phi \cos\theta \quad \text{(X channel)} \end{aligned} $$
Order 2: $$ \begin{aligned} Y_2^{-2} &= \sqrt{\frac{3}{4}} \cos^2\phi \sin(2\theta) \quad \text{(V channel)} \ Y_2^{-1} &= \sqrt{\frac{3}{4}} \sin(2\phi) \sin\theta \quad \text{(T channel)} \ Y_2^0 &= \frac{3\sin^2\phi - 1}{2} \quad \text{(R channel)} \ Y_2^1 &= \sqrt{\frac{3}{4}} \sin(2\phi) \cos\theta \quad \text{(S channel)} \ Y_2^2 &= \sqrt{\frac{3}{4}} \cos^2\phi \cos(2\theta) \quad \text{(U channel)} \end{aligned} $$
Order 3 follows similar patterns with $\cos(3\theta)$, $\sin(3\theta)$, and higher powers of $\cos\phi$ and $\sin\phi$.
Intuition for Spherical Harmonics
- W (order 0): Captures overall level—same in all directions
- X, Y, Z (order 1): Capture front-back, left-right, and up-down gradients
- Higher orders: Capture increasingly fine directional detail
A source at the front ($\theta=0, \phi=0$) encodes as:
- $W = 1$ (present everywhere)
- $Y = 0$ (no left-right component)
- $Z = 0$ (on horizon)
- $X = 1$ (fully in front)
Panning Modes
Stereo Mode
Traditional left-right panning using constant-power pan law.
#![allow(unused)] fn main() { use bbx_dsp::{blocks::PannerBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Position: -100 (left) to +100 (right) let pan = builder.add(PannerBlock::new(0.0)); // Center let pan = builder.add(PannerBlock::new_stereo(-50.0)); // Half left }
Surround Mode (VBAP)
Uses Vector Base Amplitude Panning for 5.1 and 7.1 speaker layouts.
#![allow(unused)] fn main() { use bbx_dsp::{blocks::PannerBlock, channel::ChannelLayout, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 6); // 5.1 surround panner let pan = builder.add(PannerBlock::new_surround(ChannelLayout::Surround51)); // 7.1 surround panner let pan = builder.add(PannerBlock::new_surround(ChannelLayout::Surround71)); }
Control source position with azimuth and elevation parameters.
Ambisonic Mode
Encodes mono input to SN3D normalized, ACN ordered B-format for immersive audio.
#![allow(unused)] fn main() { use bbx_dsp::{blocks::PannerBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 4); // First-order ambisonics (4 channels) let pan = builder.add(PannerBlock::new_ambisonic(1)); // Second-order ambisonics (9 channels) let pan = builder.add(PannerBlock::new_ambisonic(2)); // Third-order ambisonics (16 channels) let pan = builder.add(PannerBlock::new_ambisonic(3)); }
Port Layout
Port counts vary by mode:
| Mode | Inputs | Outputs |
|---|---|---|
| Stereo | 1 | 2 |
| Surround 5.1 | 1 | 6 |
| Surround 7.1 | 1 | 8 |
| Ambisonic FOA | 1 | 4 |
| Ambisonic SOA | 1 | 9 |
| Ambisonic TOA | 1 | 16 |
Parameters
| Parameter | Type | Range | Mode | Default |
|---|---|---|---|---|
| position | f32 | -100.0 to 100.0 | Stereo | 0.0 |
| azimuth | f32 | -180.0 to 180.0 | Surround, Ambisonic | 0.0 |
| elevation | f32 | -90.0 to 90.0 | Surround, Ambisonic | 0.0 |
Position Values (Stereo)
| Value | Position |
|---|---|
| -100.0 | Hard left |
| -50.0 | Half left |
| 0.0 | Center |
| +50.0 | Half right |
| +100.0 | Hard right |
Azimuth/Elevation (Surround/Ambisonic)
| Azimuth | Direction |
|---|---|
| 0 | Front |
| 90 | Left |
| -90 | Right |
| 180 / -180 | Rear |
| Elevation | Direction |
|---|---|
| 0 | Horizon |
| 90 | Above |
| -90 | Below |
Usage Examples
Basic Stereo Panning
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{OscillatorBlock, PannerBlock}, 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 pan = builder.add(PannerBlock::new(50.0)); // Slightly right builder.connect(osc, 0, pan, 0); }
Auto-Pan with LFO
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{LfoBlock, OscillatorBlock, PannerBlock}, 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 lfo = builder.add(LfoBlock::new(0.25, 1.0, Waveform::Sine, None)); // 0.25 Hz, full depth let pan = builder.add(PannerBlock::new(0.0)); builder.connect(osc, 0, pan, 0); builder.modulate(lfo, pan, "position"); }
Surround Panning with VBAP
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{LfoBlock, OscillatorBlock, PannerBlock}, channel::ChannelLayout, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 6); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let pan = builder.add(PannerBlock::new_surround(ChannelLayout::Surround51)); builder.connect(osc, 0, pan, 0); // Modulate azimuth for circular motion let lfo = builder.add(LfoBlock::new(0.1, 1.0, Waveform::Sine, None)); builder.modulate(lfo, pan, "azimuth"); }
Ambisonic Encoding with Rotating Source
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{LfoBlock, OscillatorBlock, PannerBlock}, channel::ChannelLayout, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 4); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let encoder = builder.add(PannerBlock::new_ambisonic(1)); builder.connect(osc, 0, encoder, 0); // Rotate source around listener let az_lfo = builder.add(LfoBlock::new(0.2, 1.0, Waveform::Sine, None)); builder.modulate(az_lfo, encoder, "azimuth"); // Output channels: W, Y, Z, X (ACN order) }
Full Ambisonics Pipeline
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{AmbisonicDecoderBlock, OscillatorBlock, PannerBlock}, channel::ChannelLayout, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Source let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Encode to FOA let encoder = builder.add(PannerBlock::new_ambisonic(1)); builder.connect(osc, 0, encoder, 0); // Decode to stereo for headphones let decoder = builder.add(AmbisonicDecoderBlock::new(1, ChannelLayout::Stereo)); builder.connect(encoder, 0, decoder, 0); // W builder.connect(encoder, 1, decoder, 1); // Y builder.connect(encoder, 2, decoder, 2); // Z builder.connect(encoder, 3, decoder, 3); // X }
Implementation Notes
- Click-free panning via linear smoothing on all parameters
- Uses
ChannelConfig::Explicit(handles routing internally) - All modes accept mono input (1 channel)
- VBAP normalizes gains for energy preservation
- Ambisonic encoding uses SN3D normalization and ACN channel ordering
Further Reading
- Pulkki, V. (1997). "Virtual Sound Source Positioning Using Vector Base Amplitude Panning." JAES, 45(6), 456-466.
- Zotter, F. & Frank, M. (2019). Ambisonics: A Practical 3D Audio Theory for Recording, Studio Production, Sound Reinforcement, and Virtual Reality. Springer.
- Daniel, J. (2001). Représentation de champs acoustiques, application à la transmission et à la reproduction de scènes sonores complexes dans un contexte multimédia. PhD thesis.
- Chapman, M. et al. (2009). "A Standard for Interchange of Ambisonic Signal Sets." Ambisonics Symposium.
OverdriveBlock
Asymmetric soft-clipping distortion with tone control.
Overview
OverdriveBlock applies warm, tube-like distortion using hyperbolic tangent saturation with different curves for positive and negative signal halves. This asymmetric approach creates a more organic sound than symmetric clipping.
Mathematical Foundation
What is Distortion?
Distortion occurs when a system's output is a nonlinear function of its input. In audio, this nonlinearity creates new frequencies (harmonics) that weren't present in the original signal.
For a pure sine wave input at frequency $f$:
- Linear system: Output is still just frequency $f$
- Nonlinear system: Output contains $f$, $2f$, $3f$, $4f$... (harmonics)
Soft vs Hard Clipping
Hard clipping simply limits the signal: $$ y = \begin{cases} 1 & \text{if } x > 1 \ x & \text{if } |x| \leq 1 \ -1 & \text{if } x < -1 \end{cases} $$
This creates harsh distortion with many high-frequency harmonics.
Soft clipping uses a smooth saturation curve: $$ y = \tanh(x) $$
The hyperbolic tangent smoothly approaches $\pm 1$ as $|x| \to \infty$, creating a warmer, more musical distortion.
The Hyperbolic Tangent
The tanh function is ideal for soft clipping:
$$ \tanh(x) = \frac{e^x - e^{-x}}{e^x + e^{-x}} $$
Properties:
- Output range: $(-1, 1)$
- Passes through origin: $\tanh(0) = 0$
- Odd function: $\tanh(-x) = -\tanh(x)$
- Smooth everywhere
- Approaches $\pm 1$ asymptotically
Saturation Curve with Adjustable Knee
This implementation uses a scaled tanh to control the "knee" (how abruptly clipping begins):
$$ y = \frac{\tanh(k \cdot x)}{k} $$
where $k$ is the knee factor (this block uses $k = 1.5$).
- Larger $k$: Sharper knee, more aggressive clipping
- Smaller $k$: Softer knee, more gradual compression
Asymmetric Saturation
Real tube amplifiers and analog circuits often clip asymmetrically—the positive and negative halves of the waveform are processed differently. This creates even harmonics (2nd, 4th, 6th...) in addition to odd harmonics.
This block applies different scaling to positive and negative signals:
$$ y = \begin{cases} 1.4 \cdot \text{softclip}(0.7x) & \text{if } x > 0 \quad \text{(softer, more headroom)} \ 0.8 \cdot \text{softclip}(1.2x) & \text{if } x < 0 \quad \text{(harder, more compression)} \end{cases} $$
where: $$ \text{softclip}(x) = \frac{\tanh(1.5x)}{1.5} $$
Harmonic Content
| Clipping Type | Harmonics Generated | Character |
|---|---|---|
| Symmetric soft | Odd only (3rd, 5th, 7th...) | Clean, tube-like |
| Asymmetric soft | Odd + Even (2nd, 3rd, 4th...) | Warm, organic |
| Hard clip | Many high-order | Harsh, buzzy |
The 2nd harmonic is an octave above the fundamental and adds "warmth." The 3rd harmonic is an octave + fifth, adding "body."
Tone Control Filter
After clipping, a one-pole low-pass filter controls brightness:
$$ y[n] = y[n-1] + \alpha \cdot (x[n] - y[n-1]) $$
where: $$ \alpha = 1 - e^{-2\pi f_c / f_s} $$
The cutoff frequency $f_c$ is mapped from the tone parameter:
- Tone 0.0: $f_c \approx 300$ Hz (dark)
- Tone 1.0: $f_c \approx 3000$ Hz (bright)
Creating an Overdrive
#![allow(unused)] fn main() { use bbx_dsp::{blocks::OverdriveBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Parameters: drive, level, tone, sample_rate let od = builder.add(OverdriveBlock::new(2.0, 0.8, 0.5, 44100.0)); }
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0 | Input | Audio input |
| 0 | Output | Distorted audio |
Parameters
| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
| drive | f64 | 1.0 - 10.0+ | 1.0 | Input gain before clipping |
| level | f64 | 0.0 - 1.0 | 1.0 | Output level |
| tone | f64 | 0.0 - 1.0 | 0.5 | Brightness (0=dark, 1=bright) |
Drive Values
| Drive | Character |
|---|---|
| 1.0 | Subtle warmth |
| 3.0 | Moderate saturation |
| 5.0 | Heavy overdrive |
| 10.0+ | Aggressive distortion |
Usage Examples
Basic Overdrive
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{OscillatorBlock, OverdriveBlock}, 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 od = builder.add(OverdriveBlock::new(3.0, 0.7, 0.5, 44100.0)); builder.connect(osc, 0, od, 0); }
Aggressive Distortion
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{OscillatorBlock, OverdriveBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let osc = builder.add(OscillatorBlock::new(110.0, Waveform::Saw, None)); let od = builder.add(OverdriveBlock::new(8.0, 0.5, 0.6, 44100.0)); // High drive, lower output builder.connect(osc, 0, od, 0); }
With DC Blocker
Distortion can introduce DC offset:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{DcBlockerBlock, OscillatorBlock, OverdriveBlock}, 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 od = builder.add(OverdriveBlock::new(5.0, 0.7, 0.5, 44100.0)); let dc = builder.add(DcBlockerBlock::new(true)); builder.connect(osc, 0, od, 0); builder.connect(od, 0, dc, 0); }
Warm Bass Distortion
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{OscillatorBlock, OverdriveBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let osc = builder.add(OscillatorBlock::new(80.0, Waveform::Saw, None)); let od = builder.add(OverdriveBlock::new(2.0, 0.8, 0.3, 44100.0)); // Low tone for warmth builder.connect(osc, 0, od, 0); }
Implementation Notes
- Asymmetric soft clipping using scaled tanh
- One-pole low-pass filter for tone control
- Click-free parameter changes via smoothing
- Denormal flushing in filter state
- Multi-channel with independent filter states
Further Reading
- Pirkle, W. (2019). Designing Audio Effect Plugins in C++, Chapter 19: Distortion Effects. Focal Press.
- Smith, J.O. (2010). Physical Audio Signal Processing, Appendix I: Nonlinear Distortion. online
- Schimmel, J. (2008). "Nonlinear Filtering in Computer Music." Proceedings of DAFx.
- Pakarinen, J. & Yeh, D. (2009). "A Review of Digital Techniques for Modeling Vacuum-Tube Guitar Amplifiers." Computer Music Journal, 33(2).
DcBlockerBlock
DC offset removal using a first-order high-pass filter.
Overview
DcBlockerBlock removes DC offset (constant voltage) from audio signals, preventing speaker damage and headroom loss. It uses a very low-frequency high-pass filter that passes all audible content while removing the DC component.
Mathematical Foundation
What is DC Offset?
DC offset occurs when a signal's average value is not zero. In audio, signals should oscillate symmetrically around zero:
Centered signal: Signal with DC offset:
+ +
__/ \__ __/ \__
--/------\-- --/------\-- ← shifted up
\_ _/ \_ _/
--
Why Remove DC?
A constant (DC) component wastes headroom and causes problems:
- Reduced headroom: Asymmetric clipping occurs sooner
- Speaker damage: DC current heats voice coils
- Incorrect metering: Peak meters show false values
- Downstream effects: Some effects (like compressors) behave poorly
The High-Pass Filter
A DC blocker is simply a high-pass filter with a very low cutoff frequency (~5 Hz). This removes the DC component (0 Hz) while passing all audible frequencies (20 Hz and above).
First-Order High-Pass
The difference equation for a first-order high-pass filter:
$$ y[n] = x[n] - x[n-1] + R \cdot y[n-1] $$
where:
- $x[n]$ is the current input sample
- $y[n]$ is the current output sample
- $R$ is the filter coefficient (typically ~0.995)
Filter Coefficient
The coefficient $R$ determines the cutoff frequency:
$$ R = 1 - \frac{2\pi f_c}{f_s} $$
where $f_c$ is the cutoff frequency (5 Hz) and $f_s$ is the sample rate.
For 5 Hz cutoff at 44.1 kHz: $$ R = 1 - \frac{2\pi \times 5}{44100} \approx 0.9993 $$
The coefficient is clamped to [0.9, 0.9999] for stability.
Transfer Function
The Z-domain transfer function:
$$ H(z) = \frac{1 - z^{-1}}{1 - R \cdot z^{-1}} $$
Pole-Zero Analysis
- Zero at $z = 1$ (DC is completely blocked)
- Pole at $z = R$ (just inside the unit circle)
The zero at $z = 1$ guarantees complete DC rejection. The pole close to $z = 1$ creates a gentle rolloff.
Frequency Response
The magnitude response:
$$ |H(f)| = \frac{\sqrt{2(1 - \cos(2\pi f / f_s))}}{\sqrt{1 + R^2 - 2R\cos(2\pi f / f_s)}} $$
Key points:
- At DC ($f = 0$): $|H| = 0$ (complete rejection)
- At cutoff ($f = f_c$): $|H| \approx 0.707$ (-3 dB)
- At 20 Hz: $|H| > 0.99$ (minimal attenuation)
Creating a DC Blocker
#![allow(unused)] fn main() { use bbx_dsp::{blocks::DcBlockerBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let dc = builder.add(DcBlockerBlock::new(true)); }
Or with direct construction:
#![allow(unused)] fn main() { use bbx_dsp::blocks::DcBlockerBlock; // Enabled DC blocker let dc = DcBlockerBlock::<f32>::new(true); // Disabled (bypass) let dc = DcBlockerBlock::<f32>::new(false); }
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0 | Input | Audio with DC |
| 0 | Output | DC-free audio |
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| enabled | bool | true | Enable/disable filtering |
Usage Examples
After Distortion
Asymmetric distortion often introduces DC offset:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{DcBlockerBlock, OscillatorBlock, OverdriveBlock}, 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 drive = builder.add(OverdriveBlock::new(5.0, 0.7, 0.5, 44100.0)); let dc = builder.add(DcBlockerBlock::new(true)); builder.connect(osc, 0, drive, 0); builder.connect(drive, 0, dc, 0); }
On External Input
Remove DC from external audio sources:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{DcBlockerBlock, FileInputBlock}, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let input = builder.add(FileInputBlock::new(Box::new(reader))); let dc = builder.add(DcBlockerBlock::new(true)); builder.connect(input, 0, dc, 0); }
Output Stage
Standard practice to include DC blocking on the master output:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{DcBlockerBlock, GainBlock, OscillatorBlock, OutputBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); let gain = builder.add(GainBlock::new(-12.0, None)); let dc = builder.add(DcBlockerBlock::new(true)); let output = builder.add(OutputBlock::new(2)); builder.connect(osc, 0, gain, 0); builder.connect(gain, 0, dc, 0); builder.connect(dc, 0, output, 0); }
When to Use
Do use after:
- Distortion/saturation effects
- Asymmetric waveshaping
- Sample rate conversion
- External audio input
- Mixing multiple sources
Don't use:
- In series (one is enough)
- When sub-bass content is critical (though 5 Hz is inaudible)
- On already DC-free signals (adds unnecessary processing)
Implementation Notes
- First-order high-pass filter (~5 Hz cutoff)
- Coefficient recalculated when sample rate changes
- Denormals flushed to prevent CPU slowdown
- Multi-channel with independent filter states
- Can be disabled for bypass (passthrough mode)
- Resettable filter state via
reset()method
Further Reading
- Smith, J.O. (2007). Introduction to Digital Filters, Chapter 1. online
- Orfanidis, S. (2010). Introduction to Signal Processing, Chapter 4. Rutgers.
- Pirkle, W. (2019). Designing Audio Effect Plugins in C++, Chapter 6: Filters. Focal Press.
ChannelRouterBlock
Flexible channel routing and manipulation.
Overview
ChannelRouterBlock provides various channel routing options for stereo audio, including swapping, duplicating, and inverting channels.
Creating a Router
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{ChannelMode, ChannelRouterBlock}, graph::GraphBuilder, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Constructor: new(mode, mono, invert_left, invert_right) let router = builder.add( ChannelRouterBlock::new(ChannelMode::Stereo, false, false, false) ); }
Constructor Parameters
| Parameter | Type | Description |
|---|---|---|
| mode | ChannelMode | Channel routing mode |
| mono | bool | Sum to mono (L+R)/2 on both channels |
| invert_left | bool | Invert left channel phase |
| invert_right | bool | Invert right channel phase |
Channel Modes
#![allow(unused)] fn main() { use bbx_dsp::blocks::ChannelMode; ChannelMode::Stereo // Pass through unchanged ChannelMode::Left // Left channel to both outputs ChannelMode::Right // Right channel to both outputs ChannelMode::Swap // Swap left and right }
Mode Details
| Mode | Left Out | Right Out |
|---|---|---|
| Stereo | Left In | Right In |
| Left | Left In | Left In |
| Right | Right In | Right In |
| Swap | Right In | Left In |
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0 | Input | Left channel |
| 1 | Input | Right channel |
| 0 | Output | Left channel |
| 1 | Output | Right channel |
Usage Examples
Mono Summing
For mono compatibility checking:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{ChannelMode, ChannelRouterBlock}, graph::GraphBuilder, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Route left to both channels let mono = builder.add( ChannelRouterBlock::new(ChannelMode::Left, false, false, false) ); }
Swap Channels
For correcting reversed cables:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{ChannelMode, ChannelRouterBlock}, graph::GraphBuilder, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let swap = builder.add( ChannelRouterBlock::new(ChannelMode::Swap, false, false, false) ); }
Phase Inversion
For polarity correction or creative effects:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{ChannelMode, ChannelRouterBlock}, graph::GraphBuilder, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Invert left channel phase only let phase_flip = builder.add( ChannelRouterBlock::new(ChannelMode::Stereo, false, true, false) ); }
True Mono Sum
Sum both channels to mono output:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{ChannelMode, ChannelRouterBlock}, graph::GraphBuilder, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Enable mono summing let mono_sum = builder.add( ChannelRouterBlock::new(ChannelMode::Stereo, true, false, false) ); }
Default Passthrough
For a simple stereo passthrough with no modifications:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::ChannelRouterBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Use default_new() for stereo passthrough let passthrough = builder.add(ChannelRouterBlock::default_new()); }
Implementation Notes
- Zero processing latency
- No allocation during process
- Simple sample copying/routing
ChannelSplitterBlock
Splits multi-channel input into individual mono outputs.
Overview
ChannelSplitterBlock separates a multi-channel signal into individual mono outputs, allowing downstream blocks to process each channel independently.
Creating a Splitter
#![allow(unused)] fn main() { use bbx_dsp::{blocks::ChannelSplitterBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Split 6 channels (e.g., 5.1 surround) let splitter = builder.add(ChannelSplitterBlock::new(6)); }
Or with direct construction:
#![allow(unused)] fn main() { use bbx_dsp::blocks::ChannelSplitterBlock; let splitter = ChannelSplitterBlock::<f32>::new(4); }
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0..N | Input | Multi-channel input |
| 0..N | Output | Individual mono outputs |
Input and output counts are equal, determined by the channels parameter (1-16).
Parameters
| Parameter | Type | Range | Description |
|---|---|---|---|
| channels | usize | 1-16 | Number of channels to split |
Usage Examples
Split Stereo for Independent Processing
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{ChannelMergerBlock, ChannelSplitterBlock, GainBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Source with stereo output let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Split stereo to mono channels let splitter = builder.add(ChannelSplitterBlock::new(2)); builder.connect(osc, 0, splitter, 0); builder.connect(osc, 0, splitter, 1); // Process left channel differently let left_gain = builder.add(GainBlock::new(-3.0, None)); builder.connect(splitter, 0, left_gain, 0); // Process right channel differently let right_gain = builder.add(GainBlock::new(-6.0, None)); builder.connect(splitter, 1, right_gain, 0); // Merge back to stereo let merger = builder.add(ChannelMergerBlock::new(2)); builder.connect(left_gain, 0, merger, 0); builder.connect(right_gain, 0, merger, 1); }
Split 5.1 Surround for Per-Channel Effects
#![allow(unused)] fn main() { use bbx_dsp::{blocks::ChannelSplitterBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 6); // Split 6-channel surround input let splitter = builder.add(ChannelSplitterBlock::new(6)); // Now you can process L, R, C, LFE, Ls, Rs independently // splitter output 0 = L // splitter output 1 = R // splitter output 2 = C // splitter output 3 = LFE // splitter output 4 = Ls // splitter output 5 = Rs }
Implementation Notes
- Zero-latency operation (direct copy)
- Uses
ChannelConfig::Explicit(handles routing internally) - Panics if
channelsis 0 or greater than 16
ChannelMergerBlock
Merges individual mono inputs into a multi-channel output.
Overview
ChannelMergerBlock combines separate mono signals into a single multi-channel stream, typically used after splitting and processing channels independently.
Creating a Merger
#![allow(unused)] fn main() { use bbx_dsp::{blocks::ChannelMergerBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Merge 6 mono inputs into 6-channel output let merger = builder.add(ChannelMergerBlock::new(6)); }
Or with direct construction:
#![allow(unused)] fn main() { use bbx_dsp::blocks::ChannelMergerBlock; let merger = ChannelMergerBlock::<f32>::new(4); }
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0..N | Input | Individual mono inputs |
| 0..N | Output | Multi-channel output |
Input and output counts are equal, determined by the channels parameter (1-16).
Parameters
| Parameter | Type | Range | Description |
|---|---|---|---|
| channels | usize | 1-16 | Number of channels to merge |
Usage Examples
Merge Two Mono Signals to Stereo
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{ChannelMergerBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Two separate oscillators let osc_left = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let osc_right = builder.add(OscillatorBlock::new(442.0, Waveform::Sine, None)); // Merge to stereo let merger = builder.add(ChannelMergerBlock::new(2)); builder.connect(osc_left, 0, merger, 0); builder.connect(osc_right, 0, merger, 1); }
Reassemble Split Channels After Processing
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{ChannelMergerBlock, ChannelSplitterBlock, OverdriveBlock}, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Split stereo input let splitter = builder.add(ChannelSplitterBlock::new(2)); // Process each channel (add effects here) let left_drive = builder.add(OverdriveBlock::new(2.0, 1.0, 0.5, 44100.0)); let right_drive = builder.add(OverdriveBlock::new(4.0, 1.0, 0.8, 44100.0)); builder.connect(splitter, 0, left_drive, 0); builder.connect(splitter, 1, right_drive, 0); // Merge back to stereo let merger = builder.add(ChannelMergerBlock::new(2)); builder.connect(left_drive, 0, merger, 0); builder.connect(right_drive, 0, merger, 1); }
Create Custom Surround Layout
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{ChannelMergerBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 4); // Four separate sources let front_left = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let front_right = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let rear_left = builder.add(OscillatorBlock::new(220.0, Waveform::Sine, None)); let rear_right = builder.add(OscillatorBlock::new(220.0, Waveform::Sine, None)); // Merge to quad let merger = builder.add(ChannelMergerBlock::new(4)); builder.connect(front_left, 0, merger, 0); builder.connect(front_right, 0, merger, 1); builder.connect(rear_left, 0, merger, 2); builder.connect(rear_right, 0, merger, 3); }
Implementation Notes
- Zero-latency operation (direct copy)
- Uses
ChannelConfig::Explicit(handles routing internally) - Panics if
channelsis 0 or greater than 16 - Complement to
ChannelSplitterBlock
MatrixMixerBlock
An NxM mixing matrix for flexible channel routing with gain control.
Overview
MatrixMixerBlock provides arbitrary channel mixing where each output is a weighted sum of all inputs. This enables complex routing scenarios like downmixing, upmixing, and channel swapping.
Creating a Matrix Mixer
#![allow(unused)] fn main() { use bbx_dsp::{blocks::MatrixMixerBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // 4x2 matrix (4 inputs, 2 outputs) let mut matrix = MatrixMixerBlock::<f32>::new(4, 2); // Configure gains before adding to graph matrix.set_gain(0, 0, 1.0); // input 0 -> output 0 at unity matrix.set_gain(1, 1, 1.0); // input 1 -> output 1 at unity let mixer = builder.add(matrix); }
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0..N | Input | N input channels |
| 0..M | Output | M output channels |
Input and output counts are configured independently (1-16 each).
Parameters
| Parameter | Type | Range | Description |
|---|---|---|---|
| inputs | usize | 1-16 | Number of input channels |
| outputs | usize | 1-16 | Number of output channels |
API Methods
set_gain
Set the gain for a specific input-to-output routing:
#![allow(unused)] fn main() { mixer.set_gain(input, output, gain); }
input: Source channel index (0-based)output: Destination channel index (0-based)gain: Linear gain (typically 0.0 to 1.0)
get_gain
Get the current gain for a routing:
#![allow(unused)] fn main() { let gain = mixer.get_gain(input, output); }
identity
Create an identity matrix (passthrough):
#![allow(unused)] fn main() { let mixer = MatrixMixerBlock::<f32>::identity(4); // Input 0 -> Output 0 at 1.0 // Input 1 -> Output 1 at 1.0 // etc. }
Usage Examples
Stereo to Mono Downmix
#![allow(unused)] fn main() { use bbx_dsp::{blocks::MatrixMixerBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // 2 inputs -> 1 output let mut mixer = MatrixMixerBlock::<f32>::new(2, 1); mixer.set_gain(0, 0, 0.5); // Left -> Mono at 0.5 mixer.set_gain(1, 0, 0.5); // Right -> Mono at 0.5 let downmix = builder.add(mixer); }
Channel Swap
#![allow(unused)] fn main() { use bbx_dsp::{blocks::MatrixMixerBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // 2 inputs -> 2 outputs, swapped let mut mixer = MatrixMixerBlock::<f32>::new(2, 2); mixer.set_gain(0, 1, 1.0); // Left input -> Right output mixer.set_gain(1, 0, 1.0); // Right input -> Left output let swap = builder.add(mixer); }
Quad to Stereo Downmix
#![allow(unused)] fn main() { use bbx_dsp::{blocks::MatrixMixerBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // 4 inputs -> 2 outputs let mut mixer = MatrixMixerBlock::<f32>::new(4, 2); // Front left + rear left -> Left output mixer.set_gain(0, 0, 0.7); // Front left mixer.set_gain(2, 0, 0.5); // Rear left // Front right + rear right -> Right output mixer.set_gain(1, 1, 0.7); // Front right mixer.set_gain(3, 1, 0.5); // Rear right let downmix = builder.add(mixer); }
Mid-Side Encoding
#![allow(unused)] fn main() { use bbx_dsp::{blocks::MatrixMixerBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Stereo to M/S: M = (L+R)/2, S = (L-R)/2 let mut ms_encode = MatrixMixerBlock::<f32>::new(2, 2); ms_encode.set_gain(0, 0, 0.5); // L -> M ms_encode.set_gain(1, 0, 0.5); // R -> M ms_encode.set_gain(0, 1, 0.5); // L -> S ms_encode.set_gain(1, 1, -0.5); // -R -> S let encoder = builder.add(ms_encode); }
Implementation Notes
- All gains default to 0.0 (silent until configured)
- Uses
ChannelConfig::Explicit(handles routing internally) - Panics if
inputsoroutputsis 0 or greater than 16 - Output is sum of all weighted inputs (may need gain reduction to avoid clipping)
MixerBlock
Channel-wise audio mixing with automatic normalization.
Overview
MixerBlock sums multiple audio sources into a single output while maintaining proper gain staging. It automatically groups inputs by channel and applies normalization to prevent clipping and preserve consistent loudness.
Mathematical Foundation
Signal Summation
When $N$ audio signals are summed, the amplitude of the combined signal can theoretically reach $N$ times the amplitude of any individual signal (if all signals happen to be in phase):
$$ y[n] = \sum_{i=0}^{N-1} x_i[n] $$
Without normalization, mixing 4 signals could produce output up to 4× the input levels, causing clipping.
Normalization Strategies
Average Normalization
Divide by the number of sources:
$$ y[n] = \frac{1}{N} \sum_{i=0}^{N-1} x_i[n] $$
This ensures the output never exceeds the amplitude of any single input, but reduces overall level as more sources are added.
| Sources | Normalization Factor | dB Reduction |
|---|---|---|
| 2 | 0.5 | -6 dB |
| 3 | 0.333 | -9.5 dB |
| 4 | 0.25 | -12 dB |
| 8 | 0.125 | -18 dB |
Use case: When sources might constructively interfere (correlated signals, like multiple takes of the same performance).
Constant-Power Normalization (Default)
Divide by the square root of the number of sources:
$$ y[n] = \frac{1}{\sqrt{N}} \sum_{i=0}^{N-1} x_i[n] $$
| Sources | Normalization Factor | dB Reduction |
|---|---|---|
| 2 | 0.707 | -3 dB |
| 3 | 0.577 | -4.8 dB |
| 4 | 0.5 | -6 dB |
| 8 | 0.354 | -9 dB |
Use case: When sources are uncorrelated (different instruments, independent oscillators).
Why Constant-Power?
For uncorrelated signals, the power (not amplitude) sums:
$$ P_{total} = \sum_{i=0}^{N-1} P_i = N \cdot P_{avg} $$
The amplitude (RMS voltage) is proportional to the square root of power:
$$ V_{RMS} = \sqrt{P} \propto \sqrt{N} $$
To maintain the same output power regardless of how many sources are mixed, we divide by $\sqrt{N}$:
$$ \frac{N \cdot P_{avg}}{(\sqrt{N})^2} = \frac{N \cdot P_{avg}}{N} = P_{avg} $$
This keeps the perceived loudness roughly constant whether you're mixing 2 or 8 sources.
Correlated vs Uncorrelated Signals
Correlated signals (in-phase copies): $$ x_1(t) = x_2(t) \implies (x_1 + x_2) = 2x_1 $$ Amplitude doubles, power quadruples. Use average normalization.
Uncorrelated signals (independent): $$ \langle x_1 \cdot x_2 \rangle = 0 $$ Powers add, not amplitudes. Use constant-power normalization.
Real-world audio sources are typically uncorrelated, making constant-power the better default.
Input Organization
Inputs are grouped by channel. For stereo output with 3 sources:
Source A: Input 0 (L), Input 1 (R)
Source B: Input 2 (L), Input 3 (R)
Source C: Input 4 (L), Input 5 (R)
─────────────────────────
Output: Output 0 (L = A.L + B.L + C.L)
Output 1 (R = A.R + B.R + C.R)
Total inputs = num_sources × num_channels
Creating a Mixer
#![allow(unused)] fn main() { use bbx_dsp::{blocks::MixerBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Mix 3 stereo sources into stereo output let mixer = builder.add(MixerBlock::stereo(3)); // Or specify source and channel counts directly let mono_mixer = builder.add(MixerBlock::new(4, 1)); }
Or with custom normalization:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{MixerBlock, NormalizationStrategy}, graph::GraphBuilder, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Mix 2 stereo sources with average normalization let mixer = MixerBlock::<f32>::stereo(2) .with_normalization(NormalizationStrategy::Average); let mix = builder.add(mixer); }
Port Layout
Ports depend on configuration:
| Configuration | Inputs | Outputs |
|---|---|---|
| stereo(2) | 4 | 2 |
| stereo(3) | 6 | 2 |
| stereo(4) | 8 | 2 |
| new(3, 1) (mono) | 3 | 1 |
| new(2, 6) (5.1) | 12 | 6 |
Maximum total inputs: 16 (constrained by MAX_BLOCK_INPUTS)
Parameters
| Parameter | Type | Range | Default |
|---|---|---|---|
| num_sources | usize | 1-8 | - |
| num_channels | usize | 1-16 | - |
| normalization | enum | Average, ConstantPower | ConstantPower |
Usage Examples
Mix Two Stereo Signals
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{MixerBlock, OscillatorBlock, PannerBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Two oscillators let osc1 = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let osc2 = builder.add(OscillatorBlock::new(880.0, Waveform::Sine, None)); // Pan them to different positions let pan1 = builder.add(PannerBlock::new_stereo(-50.0)); let pan2 = builder.add(PannerBlock::new_stereo(50.0)); builder.connect(osc1, 0, pan1, 0); builder.connect(osc2, 0, pan2, 0); // Mix the stereo signals together let mixer = builder.add(MixerBlock::stereo(2)); builder .connect(pan1, 0, mixer, 0) // Source A left .connect(pan1, 1, mixer, 1) // Source A right .connect(pan2, 0, mixer, 2) // Source B left .connect(pan2, 1, mixer, 3); // Source B right }
Mix Three Mono Sources
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{MixerBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 1); let osc1 = builder.add(OscillatorBlock::new(220.0, Waveform::Sine, None)); let osc2 = builder.add(OscillatorBlock::new(330.0, Waveform::Sine, None)); let osc3 = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // 3 mono sources -> 1 mono output let mixer = builder.add(MixerBlock::new(3, 1)); builder .connect(osc1, 0, mixer, 0) .connect(osc2, 0, mixer, 1) .connect(osc3, 0, mixer, 2); }
Layered Synth
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{MixerBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Three detuned oscillators let osc1 = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); let osc2 = builder.add(OscillatorBlock::new(440.0 * 1.003, Waveform::Saw, None)); // +5 cents let osc3 = builder.add(OscillatorBlock::new(440.0 / 1.003, Waveform::Saw, None)); // -5 cents let mixer = builder.add(MixerBlock::stereo(3)); // Mix all three (mono -> stereo via single-channel inputs) builder.connect(osc1, 0, mixer, 0); builder.connect(osc1, 0, mixer, 1); builder.connect(osc2, 0, mixer, 2); builder.connect(osc2, 0, mixer, 3); builder.connect(osc3, 0, mixer, 4); builder.connect(osc3, 0, mixer, 5); }
Custom Normalization
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{MixerBlock, NormalizationStrategy}, graph::GraphBuilder, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Use average normalization instead of constant power let mixer = MixerBlock::<f32>::stereo(4) .with_normalization(NormalizationStrategy::Average); let mix = builder.add(mixer); }
Implementation Notes
- Uses
ChannelConfig::Explicit(handles channel routing internally) - Default normalization is
ConstantPowerfor natural-sounding mixes - Panics if
num_sourcesornum_channelsis 0 - Panics if total inputs exceed
MAX_BLOCK_INPUTS(16) - Zero-allocation processing
Further Reading
- Huber, D. & Runstein, R. (2017). Modern Recording Techniques, Chapter 3: Mixers. Focal Press.
- Katz, B. (2015). Mastering Audio, Chapter 4: Gain Staging. Focal Press.
- Ballou, G. (2015). Handbook for Sound Engineers, Chapter 24: Mixing Consoles. Focal Press.
AmbisonicDecoderBlock
Decodes ambisonics B-format to speaker layouts.
Overview
AmbisonicDecoderBlock converts SN3D normalized, ACN ordered ambisonic signals to discrete speaker feeds using a mode-matching decoder. This enables playback of ambisonic content on standard speaker configurations.
Creating a Decoder
#![allow(unused)] fn main() { use bbx_dsp::{blocks::AmbisonicDecoderBlock, channel::ChannelLayout, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Decode first-order ambisonics to stereo let decoder = builder.add(AmbisonicDecoderBlock::new(1, ChannelLayout::Stereo)); }
Or for surround layouts:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::AmbisonicDecoderBlock, channel::ChannelLayout, graph::GraphBuilder, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 6); let decoder = builder.add(AmbisonicDecoderBlock::new(2, ChannelLayout::Surround51)); }
Supported Configurations
Input Orders
| Order | Channels | Format |
|---|---|---|
| 1 (FOA) | 4 | W, Y, Z, X |
| 2 (SOA) | 9 | W, Y, Z, X, V, T, R, S, U |
| 3 (TOA) | 16 | Full third-order |
Output Layouts
| Layout | Channels | Description |
|---|---|---|
| Mono | 1 | Single speaker |
| Stereo | 2 | L, R at +/-30 degrees |
| Surround51 | 6 | L, R, C, LFE, Ls, Rs |
| Surround71 | 8 | L, R, C, LFE, Ls, Rs, Lrs, Rrs |
| Custom(n) | n | Circular array |
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0..N | Input | Ambisonic channels (4/9/16) |
| 0..M | Output | Speaker feeds |
Input count depends on order: (order + 1)^2
Output count depends on the target layout.
Speaker Positions
Stereo
| Channel | Azimuth |
|---|---|
| Left | +30 degrees |
| Right | -30 degrees |
5.1 Surround
| Channel | Azimuth |
|---|---|
| L | +30 degrees |
| R | -30 degrees |
| C | 0 degrees |
| LFE | N/A (omnidirectional) |
| Ls | +110 degrees |
| Rs | -110 degrees |
7.1 Surround
| Channel | Azimuth |
|---|---|
| L | +30 degrees |
| R | -30 degrees |
| C | 0 degrees |
| LFE | N/A |
| Ls | +90 degrees |
| Rs | -90 degrees |
| Lrs | +150 degrees |
| Rrs | -150 degrees |
Custom Layout
For Custom(n), speakers are distributed evenly in a circle:
- Angle step: 360 degrees / n
- Starting offset: -90 degrees
Usage Examples
First-Order to Stereo
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{AmbisonicDecoderBlock, OscillatorBlock, PannerBlock}, channel::ChannelLayout, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Create ambisonic encoder let encoder = builder.add(PannerBlock::new_ambisonic(1)); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); builder.connect(osc, 0, encoder, 0); // Decode to stereo let decoder = builder.add(AmbisonicDecoderBlock::new(1, ChannelLayout::Stereo)); // Connect all 4 FOA channels builder.connect(encoder, 0, decoder, 0); // W builder.connect(encoder, 1, decoder, 1); // Y builder.connect(encoder, 2, decoder, 2); // Z builder.connect(encoder, 3, decoder, 3); // X }
Second-Order to 5.1
#![allow(unused)] fn main() { use bbx_dsp::{blocks::AmbisonicDecoderBlock, channel::ChannelLayout, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 6); // Decode SOA (9 channels) to 5.1 (6 channels) let decoder = builder.add(AmbisonicDecoderBlock::new(2, ChannelLayout::Surround51)); }
Full Ambisonics Pipeline
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{AmbisonicDecoderBlock, LfoBlock, OscillatorBlock, PannerBlock}, channel::ChannelLayout, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 8); // Source let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Encode to FOA with LFO-modulated azimuth let encoder = builder.add(PannerBlock::new_ambisonic(1)); let lfo = builder.add(LfoBlock::new(0.5, 1.0, Waveform::Sine, None)); builder.connect(osc, 0, encoder, 0); builder.modulate(lfo, encoder, "azimuth"); // Decode to 7.1 speakers let decoder = builder.add(AmbisonicDecoderBlock::new(1, ChannelLayout::Surround71)); builder.connect(encoder, 0, decoder, 0); builder.connect(encoder, 1, decoder, 1); builder.connect(encoder, 2, decoder, 2); builder.connect(encoder, 3, decoder, 3); }
Implementation Notes
- Uses mode-matching decoder with energy normalization
- SN3D normalization and ACN channel ordering
- Uses
ChannelConfig::Explicit(handles routing internally) - Panics if order is not 1, 2, or 3
- LFE channel receives minimal directional content in surround layouts
BinauralDecoderBlock
Decodes multi-channel audio to stereo for headphone listening using HRTF convolution.
Overview
BinauralDecoderBlock converts ambisonic B-format or surround sound signals to binaural stereo output. Two decoding strategies are available:
- HRTF (default): Full Head-Related Transfer Function convolution for accurate 3D spatial rendering with proper externalization
- Matrix: Lightweight ILD-based approximation for lower CPU usage
The HRTF strategy uses measured impulse responses from the MIT KEMAR database to model how sounds from different directions are filtered by the head and ears.
Decoding Strategies
BinauralStrategy::Hrtf (Default)
Uses time-domain convolution with Head-Related Impulse Responses (HRIRs) from virtual speaker positions. Provides accurate:
- ITD (Interaural Time Difference): Timing differences between ears
- ILD (Interaural Level Difference): Level differences from head shadowing
- Spectral cues: Frequency-dependent filtering from pinnae
Results in sounds that appear "outside the head" with convincing 3D positioning.
BinauralStrategy::Matrix
Uses pre-computed psychoacoustic coefficients for basic left/right panning. Lower CPU but limited spatial accuracy—sounds may appear "inside the head".
Creating a Decoder
#![allow(unused)] fn main() { use bbx_dsp::{blocks::BinauralDecoderBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Default: HRTF decoding for first-order ambisonics let decoder = builder.add(BinauralDecoderBlock::new(1)); // Surround sound (5.1 or 7.1) with HRTF let surround_decoder = builder.add(BinauralDecoderBlock::new_surround(6)); }
Or with explicit strategy selection:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{BinauralDecoderBlock, BinauralStrategy}, graph::GraphBuilder, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // HRTF decoding (default) let hrtf_decoder = builder.add(BinauralDecoderBlock::new(1)); // Matrix decoding (lightweight) let matrix_decoder = builder.add(BinauralDecoderBlock::with_strategy(2, BinauralStrategy::Matrix)); // Surround 5.1 with HRTF let surround = builder.add(BinauralDecoderBlock::new_surround_with_strategy(6, BinauralStrategy::Hrtf)); }
Supported Configurations
Ambisonic Input
| Order | Channels | Format |
|---|---|---|
| 1 (FOA) | 4 | W, Y, Z, X |
| 2 (SOA) | 9 | W, Y, Z, X, V, T, R, S, U |
| 3 (TOA) | 16 | Full third-order |
Surround Input
| Layout | Channels | Description |
|---|---|---|
| 5.1 | 6 | L, R, C, LFE, Ls, Rs |
| 7.1 | 8 | L, R, C, LFE, Ls, Rs, Lrs, Rrs |
Output
Always stereo (2 channels): Left ear, Right ear.
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0..N | Input | Multi-channel audio (4/6/8/9/16) |
| 0 | Output | Left ear |
| 1 | Output | Right ear |
Input count depends on configuration: (order + 1)^2 for ambisonics, or 6/8 for surround.
Usage Examples
First-Order Ambisonics to Binaural
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{BinauralDecoderBlock, OscillatorBlock, PannerBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Create ambisonic encoder let encoder = builder.add(PannerBlock::new_ambisonic(1)); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); builder.connect(osc, 0, encoder, 0); // Decode to binaural stereo (HRTF by default) let decoder = builder.add(BinauralDecoderBlock::new(1)); // Connect all 4 FOA channels builder.connect(encoder, 0, decoder, 0); // W builder.connect(encoder, 1, decoder, 1); // Y builder.connect(encoder, 2, decoder, 2); // Z builder.connect(encoder, 3, decoder, 3); // X }
Rotating Sound with LFO Modulation
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{BinauralDecoderBlock, LfoBlock, OscillatorBlock, PannerBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Source let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Encode to FOA with LFO-modulated azimuth let encoder = builder.add(PannerBlock::new_ambisonic(1)); let lfo = builder.add(LfoBlock::new(0.5, 1.0, Waveform::Sine, None)); builder.connect(osc, 0, encoder, 0); builder.modulate(lfo, encoder, "azimuth"); // Decode to headphones with HRTF let decoder = builder.add(BinauralDecoderBlock::new(1)); builder.connect(encoder, 0, decoder, 0); builder.connect(encoder, 1, decoder, 1); builder.connect(encoder, 2, decoder, 2); builder.connect(encoder, 3, decoder, 3); }
Surround Sound Downmix
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{BinauralDecoderBlock, FileInputBlock}, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(48000.0, 512, 2); // File input with 5.1 surround content let file = builder.add(FileInputBlock::new(Box::new(reader))); // Decode to binaural for headphone listening let decoder = builder.add(BinauralDecoderBlock::new_surround(6)); // Connect all 6 channels for ch in 0..6 { builder.connect(file, ch, decoder, ch); } }
Comparing Strategies
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{BinauralDecoderBlock, BinauralStrategy}, graph::GraphBuilder, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // High-quality HRTF rendering let hrtf = builder.add(BinauralDecoderBlock::new(1)); // Lightweight matrix rendering let matrix = builder.add(BinauralDecoderBlock::with_strategy(1, BinauralStrategy::Matrix)); }
HRTF Implementation Details
The HRTF strategy uses a virtual speaker approach:
- Virtual speaker layout: 4 speakers at ±45° and ±135° azimuth for FOA
- Spherical harmonic decoding: Input channels weighted by SH coefficients for each speaker position
- HRIR convolution: Each speaker signal convolved with position-specific HRIRs
- Binaural summation: All convolved outputs summed for left and right ears
HRIR Data Source
HRIRs are from the MIT KEMAR database (Gardner & Martin, 1994):
- 256 samples per impulse response
- Measured on KEMAR mannequin
- Positions quantized to cardinal and 45° diagonal directions
For detailed mathematical background, see HRTF Architecture.
Implementation Notes
- Default strategy is HRTF for best spatial quality
- Uses
ChannelConfig::Explicit(handles routing internally) - SN3D normalization and ACN channel ordering for ambisonics
- Pre-allocated circular buffers for realtime-safe convolution
reset()clears convolution state (useful when seeking in playback)- Panics if ambisonic order is not 1, 2, or 3
- Panics if surround channel count is not 6 or 8
Performance Comparison
| Strategy | CPU Usage | Spatial Accuracy | Externalization |
|---|---|---|---|
| HRTF | Higher | Excellent | Yes |
| Matrix | Lower | Basic | Limited |
HRTF complexity: O(samples × speakers × hrir_length) per buffer.
See Also
- HRTF Architecture — Mathematical foundations and implementation details
- AmbisonicDecoderBlock — Decode to speaker arrays
- PannerBlock — Ambisonic encoding
- Multi-Channel System — Channel layout architecture
LowPassFilterBlock
SVF-based low-pass filter with cutoff and resonance control.
Overview
LowPassFilterBlock is a State Variable Filter (SVF) using the TPT (Topology Preserving Transform) algorithm. It provides:
- Stable filtering at all cutoff frequencies
- No delay-free loops
- Consistent behavior regardless of sample rate
Mathematical Foundation
What is a Low-Pass Filter?
A low-pass filter attenuates frequencies above a specified cutoff frequency $f_c$ while allowing lower frequencies to pass through. The rate of attenuation above the cutoff is determined by the filter's order:
- First-order: -6 dB/octave (-20 dB/decade)
- Second-order: -12 dB/octave (-40 dB/decade)
This SVF implementation is second-order, providing -12 dB/octave rolloff.
Transfer Function
A second-order low-pass filter has the general continuous-time transfer function:
$$ H(s) = \frac{\omega_c^2}{s^2 + \frac{\omega_c}{Q}s + \omega_c^2} $$
where:
- $s$ is the complex frequency variable
- $\omega_c = 2\pi f_c$ is the cutoff angular frequency
- $Q$ is the quality factor (resonance)
The Q Factor (Resonance)
The quality factor $Q$ controls the filter's behavior near the cutoff frequency:
| Q Value | Character | Peak at Cutoff |
|---|---|---|
| 0.5 | Heavily damped, gradual rolloff | No peak |
| 0.707 | Butterworth (maximally flat passband) | No peak |
| 1.0 | Slight resonance | Small peak |
| 2.0+ | Pronounced resonance | Noticeable peak |
| 10.0 | Near self-oscillation | Large peak |
The Butterworth response ($Q = \frac{1}{\sqrt{2}} \approx 0.707$) is called "maximally flat" because it has no ripple in the passband—the flattest possible response before rolloff.
At high Q values, the filter emphasizes frequencies near the cutoff, creating the classic "resonant sweep" sound when the cutoff is modulated.
The TPT/SVF Algorithm
The Topology Preserving Transform discretizes analog filter structures while maintaining their essential characteristics. Unlike naive discretization methods, TPT:
- Preserves the analog frequency response shape
- Avoids the "cramping" effect near Nyquist
- Has no delay-free loops (important for real-time stability)
Coefficient Calculation
The key coefficient $g$ uses the tangent pre-warping formula:
$$ g = \tan\left(\frac{\pi f_c}{f_s}\right) $$
This maps the analog cutoff frequency to the correct digital frequency, compensating for the frequency warping inherent in bilinear transform discretization.
The damping factor $k$ is the inverse of Q:
$$ k = \frac{1}{Q} $$
From these, we compute the filter coefficients:
$$ \begin{aligned} a_1 &= \frac{1}{1 + g(g + k)} \ a_2 &= g \cdot a_1 \ a_3 &= g \cdot a_2 \end{aligned} $$
State Variable Processing
The SVF maintains two state variables, $ic_1$ and $ic_2$, representing the integrator states. For each input sample $v_0$:
$$ \begin{aligned} v_3 &= v_0 - ic_2 \ v_1 &= a_1 \cdot ic_1 + a_2 \cdot v_3 \ v_2 &= ic_2 + a_2 \cdot ic_1 + a_3 \cdot v_3 \end{aligned} $$
The state variables are then updated using the trapezoidal integration rule:
$$ \begin{aligned} ic_1 &\leftarrow 2v_1 - ic_1 \ ic_2 &\leftarrow 2v_2 - ic_2 \end{aligned} $$
The low-pass output is $v_2$.
The elegant aspect of this structure is that it simultaneously computes low-pass, high-pass, and band-pass outputs—though this implementation only uses the low-pass.
Frequency Response
The filter's magnitude response $|H(f)|$ describes how much each frequency is attenuated:
$$ |H(f)| = \frac{1}{\sqrt{(1 - (f/f_c)^2)^2 + (f/(f_c \cdot Q))^2}} $$
Key points:
- At $f = 0$: $|H| = 1$ (unity gain at DC)
- At $f = f_c$: $|H| = Q$ (gain equals Q at cutoff)
- At $f \gg f_c$: $|H| \approx (f_c/f)^2$ (second-order rolloff)
Resonance Peak Compensation
At high Q values, the resonance peak can cause the output to exceed unity gain significantly. This implementation applies a compensation factor to limit the peak while preserving the filter character:
$$ \text{compensation} = \begin{cases} 1.0 & \text{if } Q \leq 1 \ \frac{2}{Q} \cdot \text{blend} & \text{if } Q > 1 \end{cases} $$
This keeps the maximum output level manageable (target peak ≤ 2.0) while maintaining the resonant character.
Creating a Low-Pass Filter
Using the builder:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::LowPassFilterBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Create with cutoff at 1000 Hz, resonance at 0.707 (Butterworth) let filter = builder.add(LowPassFilterBlock::new(1000.0, 0.707)); }
Direct construction:
#![allow(unused)] fn main() { use bbx_dsp::blocks::LowPassFilterBlock; let filter = LowPassFilterBlock::<f32>::new(1000.0, 0.707); }
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0 | Input | Audio input |
| 0 | Output | Filtered audio |
Parameters
| Parameter | Type | Range | Default |
|---|---|---|---|
| Cutoff | f64 | 20-20000 Hz | - |
| Resonance | f64 | 0.5-10.0 | 0.707 |
Resonance Values
| Value | Character |
|---|---|
| 0.5 | Heavily damped |
| 0.707 | Butterworth (flat) |
| 1.0 | Slight peak |
| 2.0+ | Pronounced peak |
| 10.0 | Near self-oscillation |
Usage Examples
Basic Filtering
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{LowPassFilterBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let source = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); let filter = builder.add(LowPassFilterBlock::new(2000.0, 0.707)); // Connect oscillator to filter builder.connect(source, 0, filter, 0); }
Synthesizer Voice
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{EnvelopeBlock, GainBlock, LowPassFilterBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Typical synth voice with envelope-modulated filter let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); let filter = builder.add(LowPassFilterBlock::new(1000.0, 2.0)); // Resonant let env = builder.add(EnvelopeBlock::new(0.01, 0.2, 0.5, 0.3)); let amp = builder.add(GainBlock::new(-6.0, None)); // Connect: Osc -> Filter -> Gain builder.connect(osc, 0, filter, 0); builder.connect(filter, 0, amp, 0); }
Filter Sweep with LFO
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{LfoBlock, LowPassFilterBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); let filter = builder.add(LowPassFilterBlock::new(1000.0, 4.0)); // High resonance let lfo = builder.add(LfoBlock::new(0.5, 1.0, Waveform::Sine, None)); // Slow sweep builder.connect(osc, 0, filter, 0); builder.modulate(lfo, filter, "cutoff"); }
Implementation Notes
- Uses TPT SVF algorithm for numerical stability
- Multi-channel processing (up to MAX_BLOCK_OUTPUTS channels)
- Independent state per channel
- Coefficients recalculated per buffer (supports modulation)
- Denormals flushed to prevent CPU stalls
Further Reading
- Zavalishin, V. (2012). The Art of VA Filter Design. Native Instruments. PDF
- Smith, J.O. (2007). Introduction to Digital Filters. online
- Pirkle, W. (2019). Designing Audio Effect Plugins in C++, Chapter 11: Filters.
- Butterworth, S. (1930). "On the Theory of Filter Amplifiers." Experimental Wireless, 7, 536-541.
Modulators
Modulator blocks generate control signals for parameter modulation.
Available Modulators
| Block | Description |
|---|---|
| LfoBlock | Low-frequency oscillator |
| EnvelopeBlock | ADSR envelope |
Characteristics
Modulators have:
- 0 audio inputs - They generate control signals
- 0 audio outputs - Output is via modulation system
- 1+ modulation outputs - Control signal outputs
Modulation vs Audio
| Aspect | Audio | Modulation |
|---|---|---|
| Rate | Sample rate | Per-block (control rate) |
| Range | -1.0 to 1.0 | -1.0 to 1.0 |
| Purpose | Listening | Parameter control |
Usage Pattern
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{LfoBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Create modulator (frequency, depth, waveform, seed) let lfo = builder.add(LfoBlock::new(5.0, 0.5, Waveform::Sine, None)); // Create oscillator let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Connect modulation using the modulate() method builder.modulate(lfo, osc, "frequency"); }
Modulation Routing
Modulators connect using the modulate() builder method:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{GainBlock, LfoBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Create LFO let lfo = builder.add(LfoBlock::new(6.0, 1.0, Waveform::Sine, None)); // Create oscillator and gain let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let gain = builder.add(GainBlock::new(-6.0, None)); // Audio connection builder.connect(osc, 0, gain, 0); // Modulation connection (source, target, parameter_name) builder.modulate(lfo, gain, "level"); }
Combining Modulators
Layer multiple modulation sources:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{GainBlock, LfoBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform, }; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Slow sweep for amplitude let slow_lfo = builder.add(LfoBlock::new(0.1, 0.5, Waveform::Sine, None)); // Fast vibrato for pitch let fast_lfo = builder.add(LfoBlock::new(6.0, 0.3, Waveform::Sine, None)); // Oscillator with vibrato let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); builder.modulate(fast_lfo, osc, "frequency"); // Gain with tremolo let gain = builder.add(GainBlock::new(-6.0, None)); builder.connect(osc, 0, gain, 0); builder.modulate(slow_lfo, gain, "level"); }
Modulatable Parameters
Common parameters that can be modulated:
| Block | Parameter | Effect |
|---|---|---|
| Oscillator | "frequency" | Vibrato |
| Oscillator | "pitch_offset" | Pitch shift |
| Gain | "level" | Tremolo |
| Panner | "position" | Auto-pan |
| LowPassFilter | "cutoff" | Filter sweep |
| Overdrive | "drive" | Drive intensity |
LfoBlock
Low-frequency oscillator for parameter modulation.
Overview
LfoBlock generates low-frequency control signals for modulating parameters like pitch, amplitude, filter cutoff, and panning. Unlike audio-rate oscillators, LFOs operate at control rate (typically < 20 Hz), producing smooth, slowly-varying signals.
Mathematical Foundation
What is Modulation?
Modulation is the process of varying one signal (the carrier) using another signal (the modulator). In synthesis:
- Carrier: The audio signal being modified (e.g., an oscillator)
- Modulator: The control signal doing the modifying (the LFO)
- Modulation depth: How much the modulator affects the carrier
Phase Accumulation
Like audio oscillators, LFOs use a phase accumulator:
$$ \phi[n] = \phi[n-1] + \Delta\phi $$
where the phase increment is:
$$ \Delta\phi = \frac{2\pi f_{LFO}}{f_s} $$
The key difference is that $f_{LFO}$ is typically 0.01-20 Hz rather than 20-20000 Hz.
Output Scaling
The raw oscillator output $w(\phi) \in [-1, 1]$ is scaled by depth:
$$ y[n] = d \cdot w(\phi[n]) $$
where $d$ is the depth parameter (0.0 to 1.0).
The final output range is $[-d, +d]$, centered at zero.
Control Rate vs Audio Rate
Audio-rate modulation (sample-by-sample):
- Frequency: 20 Hz - 20 kHz
- Creates new frequencies (sidebands)
- Used for FM synthesis, ring modulation
Control-rate modulation (per-buffer):
- Frequency: 0.01 Hz - ~20 Hz
- Smoothly varies parameters
- Used for vibrato, tremolo, auto-pan
Control-rate processing is more efficient because it computes one value per buffer rather than one per sample.
Maximum LFO Frequency
Due to control-rate operation, the maximum useful LFO frequency is limited by the Nyquist criterion for control signals:
$$ f_{max} = \frac{f_s}{2 \cdot B} $$
where $B$ is the buffer size.
For 44.1 kHz sample rate and 512-sample buffers: $$ f_{max} = \frac{44100}{2 \times 512} \approx 43 \text{ Hz} $$
Above this frequency, the LFO output will alias in the control domain.
Common Modulation Effects
| Effect | Target Parameter | Typical Rate | Typical Depth |
|---|---|---|---|
| Vibrato | Pitch/Frequency | 4-7 Hz | 0.1-0.5 |
| Tremolo | Amplitude/Gain | 4-10 Hz | 0.3-1.0 |
| Auto-pan | Pan position | 0.1-1 Hz | 0.5-1.0 |
| Filter sweep | Filter cutoff | 0.05-2 Hz | 0.3-0.8 |
| Wobble bass | Filter cutoff | 1-4 Hz | 0.5-1.0 |
Creating an LFO
#![allow(unused)] fn main() { use bbx_dsp::{blocks::LfoBlock, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let lfo = builder.add(LfoBlock::new(5.0, 0.5, Waveform::Sine, None)); }
For non-sine waveforms:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::LfoBlock, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let lfo = builder.add(LfoBlock::new(2.0, 0.8, Waveform::Triangle, None)); }
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0 | Modulation Output | Control signal (-depth to +depth) |
Parameters
| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
| frequency | f64 | 0.01 - ~43 Hz | 1.0 | Oscillation rate |
| depth | f64 | 0.0 - 1.0 | 1.0 | Output amplitude |
| waveform | Waveform | - | Sine | Shape of modulation |
| seed | Option<u64> | Any | None | Random seed (for Noise) |
Waveforms
| Waveform | Character | Use Case |
|---|---|---|
| Sine | Smooth, natural | Vibrato, tremolo |
| Triangle | Linear, symmetric | Pitch wobble |
| Sawtooth | Rising ramp | Filter sweeps |
| Square | Abrupt on/off | Gated effects |
| Noise | Random | Organic variation |
Modulation Output
The LFO output ranges from -1.0 to 1.0 (scaled by depth). The receiving block interprets this:
- Pitch: Maps to frequency deviation
- Amplitude: Maps to gain change
- Pan: Maps to position change
Usage Examples
Vibrato (Pitch Modulation)
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{LfoBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let lfo = builder.add(LfoBlock::new(5.0, 0.3, Waveform::Sine, None)); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); builder.modulate(lfo, osc, "frequency"); }
Tremolo (Amplitude Modulation)
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{GainBlock, LfoBlock, 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 lfo = builder.add(LfoBlock::new(6.0, 1.0, Waveform::Sine, None)); let gain = builder.add(GainBlock::new(-6.0, None)); builder.connect(osc, 0, gain, 0); builder.modulate(lfo, gain, "level_db"); }
Auto-Pan
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{LfoBlock, OscillatorBlock, PannerBlock}, 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 lfo = builder.add(LfoBlock::new(0.25, 1.0, Waveform::Sine, None)); let pan = builder.add(PannerBlock::new(0.0)); builder.connect(osc, 0, pan, 0); builder.modulate(lfo, pan, "position"); }
Filter Sweep
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{LfoBlock, LowPassFilterBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); let filter = builder.add(LowPassFilterBlock::new(1000.0, 4.0)); let lfo = builder.add(LfoBlock::new(0.1, 0.8, Waveform::Sine, None)); builder.connect(osc, 0, filter, 0); builder.modulate(lfo, filter, "cutoff"); }
Square LFO for Gated Effect
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{GainBlock, LfoBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); let lfo = builder.add(LfoBlock::new(4.0, 1.0, Waveform::Square, None)); let gain = builder.add(GainBlock::new(0.0, None)); builder.connect(osc, 0, gain, 0); builder.modulate(lfo, gain, "level_db"); }
Rate Guidelines
| Application | Rate Range | Notes |
|---|---|---|
| Vibrato | 4-7 Hz | Natural vocal/string range |
| Tremolo | 4-10 Hz | Faster = more intense |
| Auto-pan | 0.1-1 Hz | Slower = more subtle |
| Filter wobble | 1-4 Hz | Dubstep/bass music |
| Slow evolution | 0.01-0.1 Hz | Pad textures |
Implementation Notes
- Operates at control rate (per-buffer, not per-sample)
- Phase is continuous across buffer boundaries
- Uses band-limited waveforms (PolyBLEP) to reduce aliasing
- Deterministic output when seed is provided
- SIMD-optimized for non-Noise waveforms
Further Reading
- Roads, C. (1996). The Computer Music Tutorial, Chapter 5: Modulation Synthesis. MIT Press.
- Puckette, M. (2007). Theory and Techniques of Electronic Music, Chapter 7. World Scientific.
- Russ, M. (2012). Sound Synthesis and Sampling, Chapter 3: Modifiers. Focal Press.
EnvelopeBlock
ADSR envelope generator for amplitude and parameter modulation.
Overview
EnvelopeBlock generates time-varying control signals using the classic ADSR (Attack-Decay-Sustain-Release) model. It produces output values from 0 to 1 that can be used to shape amplitude, filter cutoff, or any other modulatable parameter.
Mathematical Foundation
The ADSR Model
An envelope is a time-varying amplitude contour that shapes how a sound evolves. The ADSR model breaks this into four stages:
- Attack: Signal rises from 0 to peak (1.0)
- Decay: Signal falls from peak to sustain level
- Sustain: Signal holds at a fixed level while key is held
- Release: Signal falls from current level to 0 when key is released
Level
^
| /\
| / \______ Sustain
| / \
| / \
|/ \______
+--A--D----S----R--> Time
State Machine
The envelope progresses through discrete states:
┌─────────────────────────────────────────────────┐
│ │
▼ │
IDLE ──note_on()──► ATTACK ──► DECAY ──► SUSTAIN ──┤
▲ │ │
│ note_off() │
│ │ │
└─────────────── RELEASE ◄───────────────┘ │
│ │
└─────── (level < floor) ─────┘
Linear Ramp Equations
This implementation uses linear ramps for simplicity and predictability.
Attack Stage:
$$ L(t) = \frac{t}{t_A} $$
where $t_A$ is the attack time and $t$ is time since note-on.
Decay Stage:
$$ L(t) = 1 - (1 - S) \cdot \frac{t}{t_D} $$
where $S$ is the sustain level and $t_D$ is decay time.
Sustain Stage:
$$ L(t) = S $$
The level holds constant at the sustain value.
Release Stage:
$$ L(t) = L_r \cdot \left(1 - \frac{t}{t_R}\right) $$
where $L_r$ is the level when release started (captured at note-off) and $t_R$ is release time.
Linear vs Exponential Curves
Linear curves (used here):
- Constant rate of change: $\frac{dL}{dt} = \text{const}$
- Predictable timing
- Sound transitions can feel abrupt at the endpoints
Exponential curves (alternative approach): $$ L(t) = L_{target} + (L_{start} - L_{target}) \cdot e^{-t/\tau} $$
- Natural decay behavior (like capacitor discharge)
- Sound transitions feel smoother
- Asymptotic approach—never truly reaches target
Envelope Floor
The implementation uses a floor threshold of $10^{-6}$ (~-120 dB) to detect when release is effectively complete:
$$ \text{if } L < 10^{-6} \text{ then } L \leftarrow 0, \text{ state} \leftarrow \text{Idle} $$
This prevents floating-point precision issues from keeping the envelope in release state indefinitely.
Creating an Envelope
#![allow(unused)] fn main() { use bbx_dsp::{blocks::EnvelopeBlock, graph::GraphBuilder}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Parameters: attack(s), decay(s), sustain(0-1), release(s) let env = builder.add(EnvelopeBlock::new(0.01, 0.2, 0.7, 0.3)); }
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0 | Output | Envelope value (0.0 to 1.0) |
Parameters
| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
| attack | f64 | 0.001 - 10.0 s | 0.01 | Time to reach peak |
| decay | f64 | 0.001 - 10.0 s | 0.1 | Time to reach sustain |
| sustain | f64 | 0.0 - 1.0 | 0.5 | Hold level (fraction of peak) |
| release | f64 | 0.001 - 10.0 s | 0.3 | Time to reach zero |
Time values are clamped to [0.001, 10.0] seconds to prevent numerical issues.
Typical Settings
| Sound Type | Attack | Decay | Sustain | Release |
|---|---|---|---|---|
| Pluck/Stab | 0.001 | 0.1 | 0.0 | 0.2 |
| Piano | 0.001 | 0.5 | 0.3 | 0.5 |
| Pad | 0.5 | 0.2 | 0.8 | 1.0 |
| Organ | 0.001 | 0.0 | 1.0 | 0.1 |
| Brass | 0.05 | 0.1 | 0.8 | 0.2 |
Usage Examples
Amplitude Envelope with VCA
The typical pattern for envelope-controlled amplitude uses a VCA:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{EnvelopeBlock, OscillatorBlock, VcaBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let env = builder.add(EnvelopeBlock::new(0.01, 0.1, 0.7, 0.3)); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let vca = builder.add(VcaBlock::new()); // Audio to VCA input 0, envelope to VCA input 1 builder.connect(osc, 0, vca, 0); builder.connect(env, 0, vca, 1); }
Pluck Sound (Short Decay)
#![allow(unused)] fn main() { let env = builder.add(EnvelopeBlock::new( 0.001, // Very fast attack 0.2, // Medium decay 0.0, // No sustain 0.1, // Short release )); }
Pad Sound (Long Attack)
#![allow(unused)] fn main() { let env = builder.add(EnvelopeBlock::new( 0.5, // Slow attack 0.3, // Medium decay 0.8, // High sustain 1.0, // Long release )); }
Percussive (No Sustain)
#![allow(unused)] fn main() { let env = builder.add(EnvelopeBlock::new( 0.002, // Instant attack 0.5, // Decay only 0.0, // No sustain 0.0, // No release )); }
Filter Envelope
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{EnvelopeBlock, LowPassFilterBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Saw, None)); let filter = builder.add(LowPassFilterBlock::new(500.0, 2.0)); let env = builder.add(EnvelopeBlock::new(0.01, 0.3, 0.2, 0.5)); builder.connect(osc, 0, filter, 0); builder.modulate(env, filter, "cutoff"); }
Control Methods
The envelope provides methods for note control:
#![allow(unused)] fn main() { // Trigger the envelope (start attack phase) env.note_on(); // Release the envelope (start release phase from current level) env.note_off(); // Reset to idle state immediately env.reset(); }
Implementation Notes
- Output range: 0.0 to 1.0
- Linear ramps for all stages
- Times clamped to [0.001, 10.0] seconds
- Envelope floor at $10^{-6}$ for reliable release termination
- Retrigger behavior: calling
note_on()restarts attack from current level
Further Reading
- Roads, C. (1996). The Computer Music Tutorial, Chapter 4: Synthesis. MIT Press.
- Puckette, M. (2007). Theory and Techniques of Electronic Music, Chapter 4. World Scientific.
- Smith, J.O. (2010). Physical Audio Signal Processing, Appendix E: Envelope Generators. online
I/O Blocks
I/O blocks handle audio input and output for graphs.
Available I/O Blocks
| Block | Description |
|---|---|
| FileInputBlock | Read from audio files |
| FileOutputBlock | Write to audio files |
| OutputBlock | Graph audio output |
Block Roles
Source (FileInputBlock)
Provides audio from external files:
#![allow(unused)] fn main() { use bbx_dsp::blocks::FileInputBlock; let reader = WavFileReader::from_path("input.wav")?; let input = builder.add(FileInputBlock::new(Box::new(reader))); }
Sink (FileOutputBlock)
Writes audio to external files:
#![allow(unused)] fn main() { use bbx_dsp::blocks::FileOutputBlock; let writer = WavFileWriter::new("output.wav", 44100.0, 2)?; let output = builder.add(FileOutputBlock::new(Box::new(writer))); }
Terminal (OutputBlock)
Collects audio for real-time output or further processing:
#![allow(unused)] fn main() { // Automatically added by GraphBuilder when building let graph = builder.build(); // Output is collected via process_buffers() }
Usage Patterns
File Processing
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{FileInputBlock, FileOutputBlock, GainBlock}, graph::GraphBuilder, }; use bbx_file::{readers::wav::WavFileReader, writers::wav::WavFileWriter}; // Set up I/O let reader = WavFileReader::from_path("input.wav")?; let writer = WavFileWriter::new("output.wav", 44100.0, 2)?; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // Create graph: Input -> Effect -> Output let file_in = builder.add(FileInputBlock::new(Box::new(reader))); let gain = builder.add(GainBlock::new(-6.0, None)); let file_out = builder.add(FileOutputBlock::new(Box::new(writer))); builder.connect(file_in, 0, gain, 0); builder.connect(gain, 0, file_out, 0); let mut graph = builder.build(); // Process entire file loop { let mut left = vec![0.0f32; 512]; let mut right = vec![0.0f32; 512]; let mut outputs: [&mut [f32]; 2] = [&mut left, &mut right]; graph.process_buffers(&mut outputs); // Check for end of file... } // Finalize output file graph.finalize(); }
Real-Time Processing
For real-time (plugin, live), use OutputBlock implicitly:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::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)); // OutputBlock added automatically let mut graph = builder.build(); // Audio callback fn process(graph: &mut Graph, output: &mut AudioBuffer) { graph.process_buffers(&mut output.channels()); } }
FileInputBlock
Read audio from files for processing in DSP graphs.
Overview
FileInputBlock wraps a Reader implementation to provide file-based audio input to a DSP graph.
Creating a File Input
#![allow(unused)] fn main() { use bbx_dsp::{blocks::FileInputBlock, graph::GraphBuilder}; use bbx_file::readers::wav::WavFileReader; let reader = WavFileReader::<f32>::from_path("input.wav")?; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let file_in = builder.add(FileInputBlock::new(Box::new(reader))); }
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0 | Output | Left channel |
| 1 | Output | Right channel (if stereo) |
| N | Output | Channel N |
Usage Examples
Basic File Playback
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{FileInputBlock, GainBlock}, graph::GraphBuilder}; let reader = WavFileReader::from_path("audio.wav")?; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let file_in = builder.add(FileInputBlock::new(Box::new(reader))); // Process through effects let gain = builder.add(GainBlock::new(-6.0, None)); builder.connect(file_in, 0, gain, 0); }
Stereo File
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{FileInputBlock, GainBlock, PannerBlock}, graph::GraphBuilder}; let reader = WavFileReader::from_path("stereo.wav")?; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let file_in = builder.add(FileInputBlock::new(Box::new(reader))); // Connect both channels let gain = builder.add(GainBlock::new(-6.0, None)); let pan = builder.add(PannerBlock::new(0.0)); // Left channel builder.connect(file_in, 0, gain, 0); // Right channel builder.connect(file_in, 1, pan, 0); }
File to File Processing
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{FileInputBlock, FileOutputBlock, OverdriveBlock}, graph::GraphBuilder}; let reader = WavFileReader::from_path("input.wav")?; let writer = WavFileWriter::new("output.wav", 44100.0, 2)?; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let file_in = builder.add(FileInputBlock::new(Box::new(reader))); let effect = builder.add(OverdriveBlock::new(3.0, 1.0, 0.8, 44100.0)); let file_out = builder.add(FileOutputBlock::new(Box::new(writer))); builder.connect(file_in, 0, effect, 0); builder.connect(effect, 0, file_out, 0); let mut graph = builder.build(); // Process all samples... graph.finalize(); }
Implementation Notes
- File is loaded into memory on creation
- Samples are read from internal buffer during process
- Looping behavior depends on implementation
- Returns zeros after file ends (no looping by default)
FileOutputBlock
Write processed audio to files.
Overview
FileOutputBlock wraps a Writer implementation to save audio from a DSP graph to disk.
Creating a File Output
#![allow(unused)] fn main() { use bbx_dsp::{blocks::FileOutputBlock, graph::GraphBuilder}; use bbx_file::writers::wav::WavFileWriter; let writer = WavFileWriter::<f32>::new("output.wav", 44100.0, 2)?; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let file_out = builder.add(FileOutputBlock::new(Box::new(writer))); }
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0 | Input | Left channel |
| 1 | Input | Right channel (if stereo) |
| N | Input | Channel N |
Usage Examples
Recording Synthesizer Output
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{FileOutputBlock, GainBlock, OscillatorBlock}, graph::GraphBuilder, waveform::Waveform}; let writer = WavFileWriter::new("synth_output.wav", 44100.0, 2)?; 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)); let file_out = builder.add(FileOutputBlock::new(Box::new(writer))); builder.connect(osc, 0, gain, 0); builder.connect(gain, 0, file_out, 0); let mut graph = builder.build(); // Generate 5 seconds of audio let samples_per_second = 44100; let total_samples = samples_per_second * 5; let buffer_size = 512; let num_buffers = total_samples / buffer_size; for _ in 0..num_buffers { let mut left = vec![0.0f32; buffer_size]; let mut right = vec![0.0f32; buffer_size]; let mut outputs: [&mut [f32]; 2] = [&mut left, &mut right]; graph.process_buffers(&mut outputs); } // Important: finalize to close the file graph.finalize(); }
Stereo Recording
#![allow(unused)] fn main() { use bbx_dsp::{blocks::{FileOutputBlock, OscillatorBlock, PannerBlock}, graph::GraphBuilder, waveform::Waveform}; let writer = WavFileWriter::new("stereo.wav", 44100.0, 2)?; let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let pan = builder.add(PannerBlock::new(25.0)); // Slightly right let file_out = builder.add(FileOutputBlock::new(Box::new(writer))); builder.connect(osc, 0, pan, 0); builder.connect(pan, 0, file_out, 0); // Left builder.connect(pan, 1, file_out, 1); // Right let mut graph = builder.build(); // Process... graph.finalize(); }
Finalization
Always call finalize() after processing:
#![allow(unused)] fn main() { graph.finalize(); }
This:
- Flushes buffered data
- Updates file headers (WAV size fields)
- Closes the file handle
Without finalization, the file may be corrupt.
Non-Blocking I/O
FileOutputBlock uses non-blocking I/O:
- Audio thread fills buffers
- Background thread writes to disk
- No blocking during process()
- Buffers are flushed during finalize()
OutputBlock
Terminal output block for collecting processed audio.
Overview
OutputBlock is the graph's audio output destination. It collects processed audio from connected blocks and provides it via process_buffers().
Automatic Creation
OutputBlock is automatically added when building a graph:
#![allow(unused)] fn main() { use bbx_dsp::{blocks::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)); // OutputBlock added automatically during build() let graph = builder.build(); }
Port Layout
| Port | Direction | Description |
|---|---|---|
| 0 | Input | Left channel |
| 1 | Input | Right channel |
| N | Input | Channel N (up to num_channels) |
Usage
Basic Usage
#![allow(unused)] fn main() { use bbx_dsp::{blocks::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)); // Oscillator automatically connects to output let mut graph = builder.build(); // Collect processed audio let mut left = vec![0.0f32; 512]; let mut right = vec![0.0f32; 512]; let mut outputs: [&mut [f32]; 2] = [&mut left, &mut right]; graph.process_buffers(&mut outputs); // 'left' and 'right' now contain the oscillator output }
Explicit Connection
For complex routing, connect blocks explicitly:
#![allow(unused)] fn main() { use bbx_dsp::{ blocks::{GainBlock, OscillatorBlock, PannerBlock}, 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)); let pan = builder.add(PannerBlock::new(0.0)); builder.connect(osc, 0, gain, 0); builder.connect(gain, 0, pan, 0); // pan's outputs will be collected let graph = builder.build(); }
Channel Count
The number of output channels is set at graph creation:
#![allow(unused)] fn main() { // Mono output let builder = GraphBuilder::<f32>::new(44100.0, 512, 1); // Stereo output let builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // 5.1 surround let builder = GraphBuilder::<f32>::new(44100.0, 512, 6); }
Output Buffer
The output buffer must match the graph's configuration:
#![allow(unused)] fn main() { let num_channels = 2; let buffer_size = 512; let mut buffers: Vec<Vec<f32>> = (0..num_channels) .map(|_| vec![0.0; buffer_size]) .collect(); let mut outputs: Vec<&mut [f32]> = buffers .iter_mut() .map(|b| b.as_mut_slice()) .collect(); graph.process_buffers(&mut outputs); }
Implementation Notes
- Terminal block (no outputs)
- Collects audio from all connected sources
- Multiple connections are summed
- Part of the graph's execution order
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
Topological Sorting
How block execution order is determined.
The Problem
DSP blocks must execute in the correct order:
Oscillator -> Gain -> Output
The oscillator must run first (it produces audio), then gain (processes it), then output (collects it).
Why Kahn's Algorithm?
Kahn's algorithm solves this by repeatedly identifying nodes that have no remaining dependencies. The core insight: if a node has in-degree zero (no incoming edges), it can safely execute next because nothing needs to run before it.
The algorithm "removes" each processed node from the graph by decrementing the in-degree of its neighbors. When a neighbor's in-degree reaches zero, it becomes a candidate for processing. This continues until all nodes are processed or a cycle is detected.
Key properties:
- O(V + E) complexity: Linear in the number of blocks (V) and connections (E)
- Non-deterministic ordering: When multiple nodes have in-degree zero simultaneously, any choice is valid. Our implementation uses LIFO ordering via
queue.pop() - Built-in cycle detection: If the algorithm terminates before processing all nodes, a cycle exists—some nodes never reach in-degree zero
- Iterative: Unlike DFS-based topological sort, Kahn's uses no recursion, avoiding stack overflow on large graphs
For DSP, this maps naturally to signal flow: sources (oscillators, file inputs) have no dependencies and process first, then their audio flows through effects to outputs.
Kahn's Algorithm
bbx_audio uses Kahn's algorithm for topological sorting:
#![allow(unused)] fn main() { fn topological_sort(&self) -> Vec<BlockId> { let mut in_degree = vec![0; self.blocks.len()]; let mut adjacency_list: HashMap<BlockId, Vec<BlockId>> = HashMap::new(); // Build graph for connection in &self.connections { adjacency_list.entry(connection.from).or_default().push(connection.to); in_degree[connection.to.0] += 1; } // Find blocks with no dependencies let mut queue = Vec::new(); for (i, °ree) in in_degree.iter().enumerate() { if degree == 0 { queue.push(BlockId(i)); } } // Process in dependency order let mut result = Vec::new(); while let Some(block) = queue.pop() { result.push(block); if let Some(neighbors) = adjacency_list.get(&block) { for &neighbor in neighbors { in_degree[neighbor.0] -= 1; if in_degree[neighbor.0] == 0 { queue.push(neighbor); } } } } result } }
Algorithm Steps
- Calculate in-degrees: Count incoming connections for each block
- Initialize queue: Add blocks with no inputs (sources)
- Process queue:
- Remove a block from queue
- Add to result
- Decrement in-degree of connected blocks
- Add newly zero-degree blocks to queue
- Result: Blocks in valid execution order
Example
Given this graph:
Osc (0) -> Gain (1) -> Output (2)
^
LFO (3) --/
Connections:
- 0 -> 1
- 3 -> 1
- 1 -> 2
In-degrees:
- Block 0: 0 (no inputs)
- Block 1: 2 (from 0 and 3)
- Block 2: 1 (from 1)
- Block 3: 0 (no inputs)
Processing:
- Queue: [0, 3] (in-degree 0)
- Pop 0, result: [0], decrement block 1
- Pop 3, result: [0, 3], decrement block 1 (now 0)
- Queue: [1], pop 1, result: [0, 3, 1], decrement block 2 (now 0)
- Queue: [2], pop 2, result: [0, 3, 1, 2]
Cycle Detection
If the result length doesn't match block count, there's a cycle:
#![allow(unused)] fn main() { if result.len() != self.blocks.len() { // Graph has a cycle - invalid! } }
Cycles are not allowed in DSP graphs (would cause infinite loops).
Buffer Management
How audio buffers are allocated and managed.
Buffer Layout
Each block's outputs get contiguous buffer indices:
Block 0 (Oscillator): 1 output -> Buffer 0
Block 1 (Panner): 2 outputs -> Buffers 1, 2
Block 2 (Output): 2 outputs -> Buffers 3, 4
Pre-Allocation
Buffers are allocated when blocks are added:
#![allow(unused)] fn main() { pub fn add_block(&mut self, block: BlockType<S>) -> BlockId { let block_id = BlockId(self.blocks.len()); // Record where this block's buffers start self.block_buffer_start.push(self.audio_buffers.len()); self.blocks.push(block); // Allocate buffers for each output let output_count = self.blocks[block_id.0].output_count(); for _ in 0..output_count { self.audio_buffers.push(AudioBuffer::new(self.buffer_size)); } block_id } }
Buffer Indexing
Fast O(1) lookup:
#![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 } }
Connection Lookup
Input connections are pre-computed for O(1) access:
#![allow(unused)] fn main() { // Pre-computed during prepare_for_playback() self.block_input_buffers = vec![Vec::new(); self.blocks.len()]; for conn in &self.connections { let buffer_idx = self.get_buffer_index(conn.from, conn.from_output); self.block_input_buffers[conn.to.0].push(buffer_idx); } // During processing - O(1) lookup let input_indices = &self.block_input_buffers[block_id.0]; }
Buffer Clearing
All buffers are zeroed at the start of each processing cycle:
#![allow(unused)] fn main() { for buffer in &mut self.audio_buffers { buffer.zeroize(); } }
This allows multiple connections to the same input (signals are summed).
Memory Efficiency
- Buffers are reused across processing cycles
- No allocations during
process_buffers() - Fixed memory footprint based on block count
Connection System
How blocks are connected in the DSP graph.
Connection Structure
#![allow(unused)] fn main() { pub struct Connection { pub from: BlockId, // Source block pub from_output: usize, // Which output port pub to: BlockId, // Destination block pub to_input: usize, // Which input port } }
Port Numbering
Ports are zero-indexed:
OscillatorBlock:
Outputs: [0] (mono audio)
PannerBlock:
Inputs: [0] (mono in)
Outputs: [0] (left), [1] (right)
Making Connections
Via GraphBuilder:
#![allow(unused)] fn main() { builder.connect( oscillator_id, // from 0, // from_output panner_id, // to 0, // to_input ); }
Connection Rules
One-to-Many
An output can connect to multiple inputs:
#![allow(unused)] fn main() { builder.connect(osc, 0, gain1, 0); builder.connect(osc, 0, gain2, 0); // Same output, different targets }
Many-to-One
Multiple outputs can connect to the same input (summed):
#![allow(unused)] fn main() { builder.connect(osc1, 0, mixer, 0); builder.connect(osc2, 0, mixer, 0); // Both summed into mixer input }
No Cycles
Connections must form a DAG (directed acyclic graph):
Valid: A -> B -> C
Invalid: A -> B -> A (cycle!)
Signal Summing
When multiple connections go to the same input:
- Buffers start zeroed
- Each source adds to the buffer
- Result is the sum of all inputs
#![allow(unused)] fn main() { // Conceptually: output_buffer[sample] += input_buffer[sample]; }
Validation
Connection validity is checked during build():
- Source and destination blocks must exist
- Port indices must be valid
- Graph must be acyclic
Multi-Channel System
This document describes how bbx_dsp handles multi-channel audio beyond basic stereo.
Channel Layout
The ChannelLayout enum describes the channel configuration for audio processing:
#![allow(unused)] fn main() { pub enum ChannelLayout { Mono, // 1 channel Stereo, // 2 channels (default) Surround51, // 6 channels: L, R, C, LFE, Ls, Rs Surround71, // 8 channels: L, R, C, LFE, Ls, Rs, Lrs, Rrs AmbisonicFoa, // 4 channels: W, Y, Z, X (1st order) AmbisonicSoa, // 9 channels (2nd order) AmbisonicToa, // 16 channels (3rd order) Custom(usize), // Arbitrary channel count } }
Channel Counts
| Layout | Channels | Description |
|---|---|---|
| Mono | 1 | Single channel |
| Stereo | 2 | Left, Right |
| Surround51 | 6 | 5.1 surround |
| Surround71 | 8 | 7.1 surround |
| AmbisonicFoa | 4 | First-order ambisonics |
| AmbisonicSoa | 9 | Second-order ambisonics |
| AmbisonicToa | 16 | Third-order ambisonics |
| Custom(n) | n | User-defined |
Ambisonic Channel Formula
For ambisonics, channel count follows: (order + 1)^2
#![allow(unused)] fn main() { ChannelLayout::ambisonic_channel_count(1) // 4 ChannelLayout::ambisonic_channel_count(2) // 9 ChannelLayout::ambisonic_channel_count(3) // 16 }
Channel Config
The ChannelConfig enum describes how a block handles multi-channel audio:
#![allow(unused)] fn main() { pub enum ChannelConfig { Parallel, // Process each channel independently (default) Explicit, // Block handles channel routing internally } }
Parallel Mode
Most DSP blocks use Parallel mode. The graph automatically duplicates the block's processing for each channel independently:
- Filters (LowPassFilter)
- Gain controls
- Distortion (Overdrive)
- DC blockers
These blocks receive the same number of input and output channels, applying identical processing to each.
Explicit Mode
Blocks with Explicit config handle their own channel routing. Use this when:
- Input and output channel counts differ
- Channels need mixing or splitting
- Spatial positioning is involved
Blocks using Explicit mode:
| Block | Inputs | Outputs | Purpose |
|---|---|---|---|
| PannerBlock | 1 | 2-16 | Position mono source in spatial field |
| ChannelSplitterBlock | N | N | Separate channels for individual processing |
| ChannelMergerBlock | N | N | Combine channels back together |
| MatrixMixerBlock | N | M | Arbitrary NxM mixing with gain control |
| AmbisonicDecoderBlock | 4/9/16 | 2-8 | Decode ambisonics to speakers |
| ChannelRouterBlock | 2 | 2 | Simple stereo routing operations |
Creating Graphs with Layouts
Use GraphBuilder::with_layout() to create a graph with a specific channel configuration:
#![allow(unused)] fn main() { use bbx_dsp::{channel::ChannelLayout, graph::GraphBuilder}; // Standard stereo let builder = GraphBuilder::<f32>::new(44100.0, 512, 2); // 5.1 surround let builder = GraphBuilder::with_layout(44100.0, 512, ChannelLayout::Surround51); // First-order ambisonics let builder = GraphBuilder::with_layout(44100.0, 512, ChannelLayout::AmbisonicFoa); }
The layout is stored in DspContext and accessible to blocks during processing.
Implementing Channel-Aware Blocks
When implementing a custom block, override channel_config() if it handles channel routing:
#![allow(unused)] fn main() { impl<S: Sample> Block<S> for MyMixerBlock<S> { fn channel_config(&self) -> ChannelConfig { ChannelConfig::Explicit } fn input_count(&self) -> usize { 4 } fn output_count(&self) -> usize { 2 } fn process( &mut self, inputs: &[&[S]], outputs: &mut [&mut [S]], modulation_values: &[S], context: &DspContext, ) { // Custom mixing logic here } } }
Buffer Limits
Multi-channel support is bounded by compile-time constants:
#![allow(unused)] fn main() { pub const MAX_BLOCK_INPUTS: usize = 16; pub const MAX_BLOCK_OUTPUTS: usize = 16; }
These limits support third-order ambisonics (16 channels) while maintaining realtime-safe stack allocation.
Head-Related Transfer Functions (HRTF)
Head-Related Transfer Functions model how sounds arriving from different directions are filtered by the listener's head, pinnae (outer ears), and torso before reaching the eardrums. This filtering enables humans to localize sounds in 3D space using only two ears.
What is HRTF?
When a sound wave travels from a source to a listener, it arrives at each ear with different characteristics depending on the source's location:
- Interaural Time Difference (ITD): Sound arrives at the near ear before the far ear
- Interaural Level Difference (ILD): The head shadows high frequencies, making the far ear quieter
- Spectral Cues: The pinnae create frequency-dependent reflections and diffractions that encode elevation and front/back information
Spatial Coordinate System
HRTF measurements use a spherical coordinate system centered on the listener:
- Azimuth (θ): The horizontal angle around the listener, measured in degrees. 0° is directly in front, 90° is to the right, 180° (or -180°) is directly behind, and -90° is to the left.
- Elevation (φ): The vertical angle above or below the horizontal plane. 0° is ear-level, +90° is directly above, and -90° is directly below.
- Frequency (ω): The angular frequency of the sound wave in radians per second (ω = 2πf where f is frequency in Hz). HRTFs are frequency-dependent because the head and ears affect different frequencies differently—low frequencies diffract around the head while high frequencies are shadowed by it, and the small structures of the pinnae only interact with wavelengths comparable to their size (roughly 1.5-17 kHz).
An HRTF captures all these cues as a frequency-domain transfer function $H(\omega, \theta, \phi)$.
HRIR: Time-Domain Representation
The Head-Related Impulse Response (HRIR) is the time-domain equivalent of an HRTF. It represents what happens to an impulse (click) traveling from a specific direction:
$$ \text{HRIR}(t, \theta, \phi) = \mathcal{F}^{-1}\left{ \text{HRTF}(\omega, \theta, \phi) \right} $$
HRIRs are typically 128-512 samples long (2.7-10.7ms at 48kHz) and encode the full binaural transformation for a single direction.
Mathematical Foundation
Binaural Rendering via Convolution
To render a mono source $x(t)$ at position $(\theta, \phi)$, convolve it with the appropriate HRIRs:
$$ \begin{aligned} y_L(t) &= x(t) * h_L(t, \theta, \phi) \ y_R(t) &= x(t) * h_R(t, \theta, \phi) \end{aligned} $$
where $*$ denotes convolution and $h_L$, $h_R$ are the left and right ear HRIRs.
Time-Domain Convolution
For an HRIR of length $N$ and input signal $x[n]$, the output $y[n]$ at sample $n$ is:
$$ y[n] = \sum_{k=0}^{N-1} x[n-k] \cdot h[k] $$
This is an FIR filter operation with the HRIR as coefficients.
Complexity Analysis
For each sample:
- Multiplications: $N$ (HRIR length)
- Additions: $N-1$
Total per audio frame of $B$ samples: $$ \text{Operations} = B \times N \times 2 \quad \text{(left + right ears)} $$
Spherical Harmonics Decomposition
For ambisonic signals, we decode to virtual speaker positions then apply HRTFs. Each virtual speaker's signal is computed by weighting ambisonic channels with spherical harmonic coefficients:
$$ s_i = \sum_{l=0}^{L} \sum_{m=-l}^{l} Y_l^m(\theta_i, \phi_i) \cdot a_{l,m} $$
where:
- $Y_l^m(\theta, \phi)$ are real spherical harmonics (SN3D normalized)
- $a_{l,m}$ is the ambisonic channel for order $l$, degree $m$
- $(\theta_i, \phi_i)$ is the virtual speaker's position
Implementation in bbx_audio
Virtual Speaker Approach
BinauralDecoderBlock uses a virtual speaker array for HRTF rendering:
- Decode ambisonic input to $N$ virtual speaker signals using SH coefficients
- Convolve each speaker signal with position-specific HRIRs
- Sum all convolved outputs for left and right ears
$$ \begin{aligned} y_L &= \sum_{i=0}^{N-1} s_i * h_{L,i} \ y_R &= \sum_{i=0}^{N-1} s_i * h_{R,i} \end{aligned} $$
HRIR Data
The implementation uses HRIR measurements from the MIT KEMAR database:
- Source: MIT Media Lab KEMAR HRTF Database (Gardner & Martin, 1994)
- Mannequin: KEMAR (Knowles Electronics Manikin for Acoustic Research)
- Length: 256 samples per HRIR
- Positions: Quantized to cardinal directions (front, back, left, right, and 45° diagonals)
Spherical Harmonic Coefficients
For a virtual speaker at azimuth $\theta$ and elevation $\phi$, the real SH coefficients (ACN/SN3D) are:
Order 0: $$ Y_0^0 = 1 $$
Order 1: $$ \begin{aligned} Y_1^{-1} &= \cos\phi \cdot \sin\theta \quad \text{(Y channel)} \ Y_1^0 &= \sin\phi \quad \text{(Z channel)} \ Y_1^1 &= \cos\phi \cdot \cos\theta \quad \text{(X channel)} \end{aligned} $$
Order 2: $$ \begin{aligned} Y_2^{-2} &= \sqrt{\frac{3}{4}} \cos^2\phi \cdot \sin(2\theta) \quad \text{(V channel)} \ Y_2^{-1} &= \sqrt{\frac{3}{4}} \sin(2\phi) \cdot \sin\theta \quad \text{(T channel)} \ Y_2^0 &= \frac{3\sin^2\phi - 1}{2} \quad \text{(R channel)} \ Y_2^1 &= \sqrt{\frac{3}{4}} \sin(2\phi) \cdot \cos\theta \quad \text{(S channel)} \ Y_2^2 &= \sqrt{\frac{3}{4}} \cos^2\phi \cdot \cos(2\theta) \quad \text{(U channel)} \end{aligned} $$
Circular Buffer Convolution
For efficient realtime processing, convolution uses a circular buffer:
#![allow(unused)] fn main() { // Store incoming sample buffer[write_pos] = input_sample; // Convolve with HRIR for k in 0..hrir_length { let buf_idx = (write_pos + hrir_length - k) % hrir_length; output += buffer[buf_idx] * hrir[k]; } // Advance write position write_pos = (write_pos + 1) % hrir_length; }
This achieves $O(N)$ convolution per sample where $N$ is HRIR length.
Decoding Strategies
BinauralDecoderBlock offers two strategies:
Matrix Strategy (Lightweight)
Uses pre-computed ILD-based coefficients without HRTF convolution:
- Lower CPU usage
- Basic left/right separation
- No ITD or spectral cues
- Sounds may appear "inside the head"
HRTF Strategy (Default)
Full HRTF convolution with virtual speakers:
- Higher CPU usage (proportional to HRIR length × speaker count)
- Accurate ITD, ILD, and spectral cues
- Better externalization (sounds appear outside the head)
- More convincing 3D positioning
Virtual Speaker Layouts
Ambisonic Decoding (FOA)
4 virtual speakers at $\pm 45°$ and $\pm 135°$ azimuth:
Front
|
FL ----+---- FR (±45°)
|
|
RL ----+---- RR (±135°)
|
Rear
Surround Sound (5.1/7.1)
Standard ITU-R speaker positions:
5.1 (ITU-R BS.775-1):
| Channel | Azimuth |
|---|---|
| L/R | $\pm 30°$ |
| C | $0°$ |
| LFE | $0°$ (non-directional) |
| Ls/Rs | $\pm 110°$ |
7.1 (ITU-R BS.2051):
| Channel | Azimuth |
|---|---|
| L/R | $\pm 30°$ |
| C | $0°$ |
| LFE | $0°$ |
| Ls/Rs | $\pm 90°$ (side) |
| Lrs/Rrs | $\pm 150°$ (rear) |
Performance Considerations
CPU Cost
HRTF convolution complexity per audio frame:
$$ \text{Operations} = B \times N_{speakers} \times L_{HRIR} \times 2 $$
For a 512-sample buffer with 4-speaker FOA and 256-sample HRIRs: $$ 512 \times 4 \times 256 \times 2 = 1,048,576 \text{ multiply-adds} $$
Memory Usage
- HRIR storage: $N_{speakers} \times 2 \times L_{HRIR} \times \text{sizeof}(f32)$
- Signal buffers: $N_{speakers} \times L_{HRIR} \times \text{sizeof}(f32)$
For 4-speaker FOA with 256-sample HRIRs:
- HRIRs: $4 \times 2 \times 256 \times 4 = 8$ KB
- Buffers: $4 \times 256 \times 4 = 4$ KB
Realtime Safety
The implementation is fully realtime-safe:
- All buffers pre-allocated at construction
- No allocations during
process() - No locks or system calls
- Circular buffer avoids memory copies
Limitations
HRIR Resolution
The current implementation uses a limited set of HRIR positions. Sounds between measured positions may exhibit less precise localization compared to interpolated or individualized HRTFs.
Head Tracking
Without head tracking, the virtual sound stage rotates with the listener's head. For immersive applications, consider integrating gyroscope data to counter-rotate the soundfield.
Individualization
Generic HRTFs (like KEMAR) work reasonably well for most listeners but optimal spatial accuracy requires individually-measured HRTFs that account for each person's unique ear geometry.
Further Reading
- Blauert, J. (1997). Spatial Hearing: The Psychophysics of Human Sound Localization. MIT Press.
- Zotter, F. & Frank, M. (2019). Ambisonics: A Practical 3D Audio Theory. Springer.
- Wightman, F.L. & Kistler, D.J. (1989). "Headphone simulation of free-field listening." JASA, 85(2), 858-867.
- MIT KEMAR Database: https://sound.media.mit.edu/resources/KEMAR.html
- AES69-2015: AES standard for file exchange - Spatial acoustic data file format (SOFA)
Real-Time Safety
Constraints and patterns for audio thread processing.
What is Real-Time Safe?
Audio processing must complete within a fixed time budget (buffer duration). Operations that can cause unbounded delays are forbidden.
Forbidden in Audio Thread
Memory Allocation
#![allow(unused)] fn main() { // BAD - Heap allocation let vec = Vec::new(); let boxed = Box::new(value); // GOOD - Pre-allocated let mut vec: StackVec<f32, 8> = StackVec::new(); }
Locking
#![allow(unused)] fn main() { // BAD - Can block let guard = mutex.lock().unwrap(); // GOOD - Lock-free let value = atomic.load(Ordering::Relaxed); }
System Calls
#![allow(unused)] fn main() { // BAD - Unbounded time std::fs::read_to_string("file.txt"); std::thread::sleep(duration); // GOOD - Pre-loaded data let data = &self.preloaded_buffer[..]; }
bbx_audio's Approach
Stack Allocation
Using StackVec for bounded collections:
#![allow(unused)] fn main() { const MAX_BLOCK_INPUTS: usize = 8; let mut input_slices: StackVec<&[S], MAX_BLOCK_INPUTS> = StackVec::new(); }
Pre-computed Lookups
Connection indices computed during prepare:
#![allow(unused)] fn main() { // Computed once in prepare_for_playback() self.block_input_buffers = vec![Vec::new(); self.blocks.len()]; // O(1) lookup during processing let input_indices = &self.block_input_buffers[block_id.0]; }
Pre-allocated Buffers
All audio buffers allocated upfront:
#![allow(unused)] fn main() { // During add_block() self.audio_buffers.push(AudioBuffer::new(self.buffer_size)); // During process - just clear buffer.zeroize(); }
Performance Considerations
Cache Efficiency
- Contiguous buffer storage
- Sequential processing order
- Minimal pointer chasing
Branch Prediction
- Consistent code paths
- Avoid data-dependent branches
- Use branchless algorithms where possible
SIMD Potential
- Buffer alignment
- Processing in chunks
- Sample-type genericity
Stack Allocation Strategy
How bbx_audio avoids heap allocations during processing.
The Problem
Audio processing must avoid heap allocation because:
malloc()/free()can take unbounded time- System allocator may lock
- Heap fragmentation over time
StackVec Solution
StackVec<T, N> provides vector-like behavior with stack storage:
#![allow(unused)] fn main() { const MAX_BLOCK_INPUTS: usize = 8; let mut input_slices: StackVec<&[S], MAX_BLOCK_INPUTS> = StackVec::new(); for &index in input_indices { input_slices.push_unchecked(/* slice */); } }
Fixed Capacity Limits
Blocks are limited to reasonable I/O counts:
#![allow(unused)] fn main() { const MAX_BLOCK_INPUTS: usize = 8; const MAX_BLOCK_OUTPUTS: usize = 8; }
These limits are validated during build():
#![allow(unused)] fn main() { assert!( connected_inputs <= MAX_BLOCK_INPUTS, "Block {idx} has too many inputs" ); }
Pre-Allocation Pattern
Storage allocated once, reused forever:
#![allow(unused)] fn main() { // In Graph struct audio_buffers: Vec<AudioBuffer<S>>, modulation_values: Vec<S>, // In prepare_for_playback() self.modulation_values.resize(self.blocks.len(), S::ZERO); // In process_buffers() - just clear, no resize for buffer in &mut self.audio_buffers { buffer.zeroize(); } }
Trade-offs
Advantages
- Zero allocations during processing
- Predictable memory layout
- No allocator contention
Disadvantages
- Fixed maximum I/O counts
- Higher upfront memory usage
- Must know limits at compile time
Denormal Prevention
How bbx_audio handles denormal floating-point numbers.
What are Denormals?
Denormal (subnormal) numbers are very small floats near zero:
Normal: 1.0e-38 (exponent > 0)
Denormal: 1.0e-40 (exponent = 0, mantissa != 0)
The Problem
Processing denormals causes severe CPU slowdowns:
- 10-100x slower operations
- Unpredictable latency spikes
- Common in audio (decaying signals)
Flush Functions
bbx_core provides flush utilities:
#![allow(unused)] fn main() { use bbx_core::{flush_denormal_f32, flush_denormal_f64}; let safe = flush_denormal_f32(maybe_denormal); }
When They Occur
- Filter feedback paths (decaying)
- Reverb/delay tails
- After gain reduction
- Envelope release phase
Usage in Blocks
Apply in feedback paths:
#![allow(unused)] fn main() { fn process_filter(&mut self, input: f32) -> f32 { let output = input + self.state * self.coefficient; self.state = flush_denormal_f32(output); output } }
Alternative Approaches
CPU FTZ/DAZ Mode
bbx_core provides the ftz-daz Cargo feature for hardware-level denormal prevention. When enabled, the enable_ftz_daz() function sets CPU flags to automatically flush denormals to zero.
Enable the feature in your Cargo.toml:
[dependencies]
bbx_core = { version = "...", features = ["ftz-daz"] }
# Or for plugins (recommended):
bbx_plugin = { version = "...", features = ["ftz-daz"] }
Usage:
#![allow(unused)] fn main() { use bbx_core::denormal::enable_ftz_daz; // Call once at the start of each audio thread enable_ftz_daz(); }
When using bbx_plugin with the ftz-daz feature, this is called automatically during prepare().
Platform Support
| Platform | Behavior |
|---|---|
| x86/x86_64 | Full FTZ + DAZ (inputs and outputs flushed) |
| AArch64 (ARM64/Apple Silicon) | FTZ only (outputs flushed) |
| Other | No-op (use software flushing) |
Note: ARM processors lack a universal DAZ equivalent, so denormal inputs are handled normally. For portable code, use flush_denormal_f32/f64 in filter feedback paths as defense-in-depth.
| Pros | Cons |
|---|---|
| No per-sample overhead | Affects all code on the thread |
| Handles all float operations automatically | ARM: outputs only (no DAZ) |
| Recommended for production plugins |
DC Offset
Add tiny DC offset to prevent zero crossing:
#![allow(unused)] fn main() { const DC_OFFSET: f32 = 1e-25; let output = (input + DC_OFFSET) * coefficient; }
Pros: Simple, portable Cons: Introduces actual DC offset
Lock-Free Patterns
Concurrency patterns used in bbx_audio.
Why Lock-Free?
Audio threads cannot wait for locks:
- Fixed time budget per buffer
- Priority inversion risks
- Unpredictable latency
SPSC Ring Buffer
For single-producer single-consumer scenarios:
#![allow(unused)] fn main() { use bbx_core::SpscRingBuffer; let (producer, consumer) = SpscRingBuffer::new::<f32>(1024); // Producer thread producer.try_push(sample); // Consumer thread (audio) if let Some(sample) = consumer.try_pop() { // Process } }
Memory Ordering
#![allow(unused)] fn main() { // Simplified concept impl SpscRingBuffer { fn try_push(&self, item: T) -> bool { let head = self.head.load(Ordering::Relaxed); let tail = self.tail.load(Ordering::Acquire); // Sync with consumer if is_full(head, tail) { return false; } self.buffer[head] = item; self.head.store(next(head), Ordering::Release); // Sync with consumer true } } }
Atomic Parameters
For real-time parameter updates:
#![allow(unused)] fn main() { use std::sync::atomic::{AtomicU32, Ordering}; struct Parameter { value: AtomicU32, } impl Parameter { fn set(&self, value: f32) { self.value.store(value.to_bits(), Ordering::Relaxed); } fn get(&self) -> f32 { f32::from_bits(self.value.load(Ordering::Relaxed)) } } }
When Locks Are OK
Not all code is audio-critical:
- Setup/teardown: Can use locks
- UI thread: Not time-critical
- File I/O: Background threads
Only the audio processing path must be lock-free.
Visualization Threading Model
This document describes the thread-safe communication patterns between audio and visualization threads in bbx_draw.
The Challenge
Audio and visualization run on different threads with different constraints:
| Thread | Priority | Deadline | Blocking |
|---|---|---|---|
| Audio | Real-time | Fixed (e.g., 5.8ms at 44.1kHz/256 samples) | Never allowed |
| Visualization | Best-effort | ~16ms (60 FPS) | Acceptable |
The audio thread cannot wait for the visualization thread. Any blocking would cause audio dropouts.
The Solution: SPSC Ring Buffer
bbx_draw uses single-producer single-consumer (SPSC) ring buffers from bbx_core:
┌─────────────────────┐ SPSC Ring Buffer ┌─────────────────────┐
│ Audio Thread │ ────────────────────────────▶ │ nannou Thread │
│ (rodio callback) │ AudioFrame packets │ (model/update/view)│
│ │ │ │
│ try_send() │ │ try_pop() │
│ (non-blocking) │ │ (consume all) │
└─────────────────────┘ └─────────────────────┘
The ring buffer uses atomic operations for lock-free synchronization. See Lock-Free Patterns for implementation details.
AudioBridgeProducer
The producer runs in the audio thread:
#![allow(unused)] fn main() { impl AudioBridgeProducer { pub fn try_send(&mut self, frame: Frame) -> bool { self.producer.try_push(frame).is_ok() } } }
Key properties:
- Non-blocking:
try_send()returns immediately - No allocation: Frame is moved, not copied
- Graceful overflow: Returns
falseif buffer full
Audio Thread Rules
#![allow(unused)] fn main() { // In audio callback - runs at audio rate (e.g., 44100/512 = ~86x per second) fn audio_callback(producer: &mut AudioBridgeProducer, samples: &[f32]) { let frame = Frame::new(samples, 44100, 2); producer.try_send(frame); // Don't check result } }
Guidelines:
- Never allocate memory
- Always use
try_send() - Don't check
is_full()before sending - Dropped frames are acceptable
AudioBridgeConsumer
The consumer runs in the visualization thread:
#![allow(unused)] fn main() { impl AudioBridgeConsumer { pub fn try_pop(&mut self) -> Option<Frame> { self.consumer.try_pop() } } }
Key properties:
- Non-blocking: Returns
Noneif empty - Batch processing: Call repeatedly to drain
- No contention: Single consumer thread
Visualization Thread Pattern
#![allow(unused)] fn main() { // In visualizer update() - runs at frame rate (e.g., 60 FPS) fn update(&mut self) { while let Some(frame) = self.consumer.try_pop() { self.process_frame(&frame); } } }
Process all available frames each visual frame to prevent buffer buildup.
Rate Mismatch
Audio generates data faster than visualization consumes it:
| Source | Rate | Data Per Visual Frame |
|---|---|---|
| Audio (44.1kHz, 512 samples) | ~86 frames/sec | ~1.4 frames |
| Visualization | 60 frames/sec | 1 frame |
The visualization thread receives ~1-2 audio frames per visual frame on average. Bursts may be larger.
Capacity Tuning
Bridge capacity affects latency and reliability:
Audio Bridge
#![allow(unused)] fn main() { let (producer, consumer) = audio_bridge(capacity); }
| Capacity | Latency | Reliability |
|---|---|---|
| 4 | ~5ms | May drop during load |
| 8 | ~10ms | Balanced |
| 16 | ~20ms | Very reliable |
Recommended: 8-16 frames for typical use.
MIDI Bridge
#![allow(unused)] fn main() { let (producer, consumer) = midi_bridge(capacity); }
MIDI is bursty (note-on/off events), not continuous:
| Capacity | Notes |
|---|---|
| 64 | Light use |
| 256 | Heavy chords, fast playing |
| 512 | Recording, dense sequences |
Recommended: 256 messages for general use.
Frame Dropping
Frame drops are acceptable for visualization:
Audio: [F1][F2][F3][F4][F5][F6]...
↓ ↓ ↓ ↓ ↓ ↓
✓ ✓ X ✓ X ✓ (X = dropped)
Visual: [F1,F2] [F4] [F6] (batched per visual frame)
The human eye won't notice missing frames when 60 visual frames per second are rendered. Audio dropouts are far more noticeable.
The Frame Type
Frame (aliased as AudioFrame in bbx_draw) uses stack allocation:
#![allow(unused)] fn main() { pub struct Frame { pub samples: StackVec<f32, MAX_FRAME_SAMPLES>, pub sample_rate: u32, pub channels: usize, } }
StackVec avoids heap allocation, making frame creation real-time safe.
MIDI vs Audio Bridges
The bridges have different drain patterns:
Audio: Pop Loop
#![allow(unused)] fn main() { while let Some(frame) = consumer.try_pop() { // Process each frame } }
Processes frames individually, suitable for buffer accumulation.
MIDI: Drain All
#![allow(unused)] fn main() { let messages = consumer.drain(); // Returns Vec<MidiMessage> for msg in messages { // Process each message } }
Collects all messages at once. Note: drain() allocates a Vec, acceptable for the visualization thread.
Best Practices
Audio Thread
- Never allocate (use
Frame::new()which uses stack) - Always call
try_send(), never block - Don't log, print, or acquire locks
- Keep processing deterministic
Visualization Thread
- Drain all available data each frame
- Don't hold references to frame data across updates
- Use smoothing/interpolation for visual continuity
- Batch expensive operations
General
- Size buffers for worst-case burst, not average
- Profile actual drop rates under load
- Consider separate bridges for different visualizers
- Test with intentional load to verify behavior
Modulation System
How parameters are modulated in bbx_audio.
Overview
Modulation allows parameters to change over time:
- LFO modulating pitch → vibrato
- Envelope modulating amplitude → ADSR
- LFO modulating filter cutoff → wah effect
Control Rate vs Audio Rate
bbx_audio uses control rate modulation:
| Aspect | Audio Rate | Control Rate |
|---|---|---|
| Updates | Per sample | Per buffer |
| CPU cost | High | Low |
| Latency | None | 1 buffer |
| Precision | Perfect | Good enough |
Most musical modulation is below 20 Hz, well within control rate capabilities.
Modulation Flow
- Modulator block processes (LFO, Envelope)
- First sample collected as modulation value
- Target block receives value in modulation array
- Block applies value to parameters
#![allow(unused)] fn main() { fn collect_modulation_values(&mut self, block_id: BlockId) { if has_modulation_outputs(block_id) { let buffer_index = self.get_buffer_index(block_id, 0); let first_sample = self.audio_buffers[buffer_index][0]; self.modulation_values[block_id.0] = first_sample; } } }
Parameter Type
#![allow(unused)] fn main() { pub enum Parameter<S: Sample> { Static(S), // Fixed value Modulated(BlockId), // Controlled by modulator } }
Routing Modulation
Use the modulate() method to connect modulators to parameters:
#![allow(unused)] fn main() { // Create LFO (frequency, depth, waveform, seed) let lfo = builder.add(LfoBlock::new(5.0, 0.5, Waveform::Sine, None)); // Create oscillator let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); // Connect modulation builder.modulate(lfo, osc, "frequency"); }
Parameter<S> Type
The generic parameter type for static and modulated values.
Definition
#![allow(unused)] fn main() { pub enum Parameter<S: Sample> { /// Fixed value Constant(S), /// Value controlled by a modulator block Modulated(BlockId), } }
Usage in Blocks
Blocks store parameters as Parameter<S>:
#![allow(unused)] fn main() { pub struct OscillatorBlock<S: Sample> { frequency: Parameter<S>, waveform: Waveform, phase: S, } }
Resolving Values
During processing, resolve the actual value:
#![allow(unused)] fn main() { impl<S: Sample> OscillatorBlock<S> { fn get_frequency(&self, modulation: &[S]) -> S { match &self.frequency { Parameter::Constant(value) => *value, Parameter::Modulated(block_id) => { let base = S::from_f64(440.0); let mod_value = modulation[block_id.0]; base * (S::ONE + mod_value * S::from_f64(0.1)) // ±10% } } } } }
Constant vs Modulated
Constant
- Value known at creation
- No per-block overhead
- Simple and direct
#![allow(unused)] fn main() { let gain = Parameter::Constant(S::from_f64(-6.0)); }
Modulated
- Value changes each buffer
- Requires modulator block
- Adds routing complexity
#![allow(unused)] fn main() { let frequency = Parameter::Modulated(lfo_block_id); }
Design Rationale
The Parameter enum:
- Unifies constant and dynamic - Same API for both
- Type-safe modulation - Compile-time block ID checking
- Zero-cost constant - No indirection for constant values
- Sample-type generic - Works with f32 and f64
Modulation Value Collection
How modulation values are collected and distributed.
Collection Process
After each modulator block processes:
#![allow(unused)] fn main() { fn collect_modulation_values(&mut self, block_id: BlockId) { // Skip if block has no modulation outputs if self.blocks[block_id.0].modulation_outputs().is_empty() { return; } // Get the first sample as the modulation value let buffer_index = self.get_buffer_index(block_id, 0); if let Some(&first_sample) = self.audio_buffers[buffer_index].get(0) { self.modulation_values[block_id.0] = first_sample; } } }
Why First Sample?
Modulation is control-rate (per-buffer), not audio-rate (per-sample):
- LFO at 5 Hz with 512-sample buffer at 44.1 kHz
- Buffer duration: 512 / 44100 ≈ 11.6 ms
- LFO phase change: 5 * 0.0116 ≈ 0.058 cycles
- Taking first sample is sufficient
Storage
Pre-allocated array indexed by block ID:
#![allow(unused)] fn main() { // In Graph modulation_values: Vec<S>, // Sized during prepare_for_playback self.modulation_values.resize(self.blocks.len(), S::ZERO); }
Passing to Blocks
Blocks receive the full modulation array:
#![allow(unused)] fn main() { block.process( input_slices.as_slice(), output_slices.as_mut_slice(), &self.modulation_values, // All values &self.context, ); }
Blocks index by their modulator's BlockId:
#![allow(unused)] fn main() { fn process(&mut self, ..., modulation: &[S], ...) { let mod_value = modulation[self.modulator_id.0]; // Apply modulation } }
Timing Considerations
Modulation is applied with 1-buffer latency:
- Buffer N: LFO generates sample
- Buffer N: Value collected
- Buffer N: Target block uses value
This is acceptable for musical modulation (typically < 20 Hz).
FFI Design
The design of bbx_audio's C FFI layer.
Goals
- Safety - Prevent memory errors across language boundary
- Simplicity - Minimal API surface
- Performance - Zero-copy audio processing
- Portability - Works with any C-compatible language
Opaque Handle Pattern
Rust types are hidden behind opaque pointers:
typedef struct BbxGraph BbxGraph; // Opaque - never dereference
C++ only sees the handle, never the Rust struct:
BbxGraph* handle = bbx_graph_create();
// handle is a type-erased pointer
Handle Lifecycle
Creation
#![allow(unused)] fn main() { #[no_mangle] pub extern "C" fn bbx_graph_create() -> *mut BbxGraph { let inner = Box::new(GraphInner::new()); Box::into_raw(inner) as *mut BbxGraph } }
Destruction
#![allow(unused)] fn main() { #[no_mangle] pub extern "C" fn bbx_graph_destroy(handle: *mut BbxGraph) { if !handle.is_null() { unsafe { drop(Box::from_raw(handle as *mut GraphInner)); } } } }
Error Handling
Return codes instead of exceptions:
typedef enum BbxError {
BBX_ERROR_OK = 0,
BBX_ERROR_NULL_POINTER = 1,
BBX_ERROR_INVALID_PARAMETER = 2,
// ...
} BbxError;
Check in C++:
BbxError err = bbx_graph_prepare(handle, ...);
if (err != BBX_ERROR_OK) {
// Handle error
}
Zero-Copy Processing
Audio buffers are passed by pointer:
void bbx_graph_process(
BbxGraph* handle,
const float* const* inputs, // Pointer to pointer
float* const* outputs,
...
);
Rust converts to slices without copying:
#![allow(unused)] fn main() { unsafe { let input_slice = std::slice::from_raw_parts(inputs[ch], num_samples); let output_slice = std::slice::from_raw_parts_mut(outputs[ch], num_samples); } }
Handle Management
How Rust objects are managed across the FFI boundary.
The Box Pattern
Rust objects are boxed and converted to raw pointers:
#![allow(unused)] fn main() { // Create: Rust -> C let inner = Box::new(GraphInner::new()); let ptr = Box::into_raw(inner); return ptr as *mut BbxGraph; // Destroy: C -> Rust let inner = Box::from_raw(ptr as *mut GraphInner); drop(inner); // Automatically called when Box goes out of scope }
Type Erasure
The C type is opaque:
typedef struct BbxGraph BbxGraph;
Rust knows the actual type:
#![allow(unused)] fn main() { type PluginGraphInner = GraphInner<PluginGraph>; }
Conversion is safe because:
- We control both sides
- Types match at compile time via generics
- Handle is never dereferenced in C
Null Safety
All functions check for null:
#![allow(unused)] fn main() { pub extern "C" fn bbx_graph_prepare(handle: *mut BbxGraph, ...) -> BbxError { if handle.is_null() { return BbxError::NullPointer; } // ... } }
Ownership Transfer
create(): Rust owns -> Box::into_raw -> C owns handle
use(): C passes handle -> Rust borrows -> returns to C
destroy(): C passes handle -> Rust reclaims -> deallocates
Create
#![allow(unused)] fn main() { Box::into_raw(inner) // Rust gives up ownership }
Use
#![allow(unused)] fn main() { let inner = &mut *(handle as *mut GraphInner); // Borrow, don't take ownership }
Destroy
#![allow(unused)] fn main() { Box::from_raw(handle) // Rust reclaims ownership // Box dropped, memory freed }
RAII in C++
The C++ wrapper manages the handle:
class Graph {
public:
Graph() : m_handle(bbx_graph_create()) {}
~Graph() { if (m_handle) bbx_graph_destroy(m_handle); }
private:
BbxGraph* m_handle;
};
Memory Safety Across Boundaries
Ensuring memory safety in the FFI layer.
Safety Invariants
1. Valid Handles
Handles are valid from create() to destroy():
#![allow(unused)] fn main() { pub extern "C" fn bbx_graph_process(handle: *mut BbxGraph, ...) { if handle.is_null() { return; // Silent no-op for safety } // Handle is valid } }
2. Non-Overlapping Buffers
Input and output buffers never overlap:
#![allow(unused)] fn main() { // SAFETY: Our buffer indexing guarantees: // 1. Input indices come from other blocks' outputs // 2. Output indices are unique to this block // 3. Therefore, input and output NEVER overlap unsafe { let input_slices = /* from input buffers */; let output_slices = /* from output buffers */; block.process(input_slices, output_slices, ...); } }
3. Valid Pointer Lengths
Buffer lengths match the provided count:
#![allow(unused)] fn main() { unsafe { let slice = std::slice::from_raw_parts( inputs[ch], num_samples as usize, // Must be accurate! ); } }
Unsafe Blocks
All unsafe code is documented:
#![allow(unused)] fn main() { // SAFETY: [explanation of why this is safe] unsafe { // unsafe operation } }
C++ Responsibilities
The C++ side must:
- Never use handle after destroy
- Provide valid buffer pointers
- Match buffer sizes to declared counts
- Not call from multiple threads simultaneously
Defense in Depth
Multiple layers of protection:
- Null checks - Explicit handle validation
- Bounds checks - Array access validation
- Type system - Compile-time generic checking
- Debug asserts - Development-time validation
#![allow(unused)] fn main() { debug_assert!( output_count <= MAX_BLOCK_OUTPUTS, "Block output count exceeds limit" ); }
Performance Considerations
Optimizing DSP performance in bbx_audio.
Key Metrics
| Metric | Target |
|---|---|
| Latency | < 1 buffer |
| CPU usage | < 50% of budget |
| Memory | Predictable, fixed |
| Allocation | Zero during process |
Optimization Strategies
Pre-computation
Calculate once, use many times:
#![allow(unused)] fn main() { // In prepare() self.coefficient = calculate_filter_coeff(self.cutoff, context.sample_rate); // In process() - just use it output = input * self.coefficient + self.state; }
Cache Efficiency
Keep hot data together:
#![allow(unused)] fn main() { // Good: Contiguous buffer storage audio_buffers: Vec<AudioBuffer<S>> // Good: Sequential processing for block_id in &self.execution_order { self.process_block(*block_id); } }
Branch Avoidance
Prefer branchless code:
#![allow(unused)] fn main() { // Avoid if condition { result = a; } else { result = b; } // Prefer let mask = condition as f32; // 0.0 or 1.0 result = a * mask + b * (1.0 - mask); }
SIMD Potential
Design for SIMD:
- Process 4+ samples at once
- Align buffers to 16/32 bytes
- Avoid data-dependent branches
Profiling
Measure before optimizing:
#![allow(unused)] fn main() { #[cfg(feature = "profiling")] let _span = tracing::span!(tracing::Level::TRACE, "process_block"); }
Common Bottlenecks
- Memory allocation in audio thread
- Cache misses from scattered data
- Branch misprediction in complex logic
- Function call overhead for tiny operations
- Denormal processing in filter feedback
Zero-Allocation Processing
How bbx_audio achieves zero allocations during audio processing.
Strategy
All memory allocated upfront:
- Blocks added → buffers allocated
- Graph prepared → connection lookups built
- Processing → only use pre-allocated memory
Pre-Allocated Resources
Audio Buffers
#![allow(unused)] fn main() { // Allocated when block is added for _ in 0..output_count { self.audio_buffers.push(AudioBuffer::new(self.buffer_size)); } // During processing - just clear buffer.zeroize(); }
Modulation Values
#![allow(unused)] fn main() { // Allocated during prepare self.modulation_values.resize(self.blocks.len(), S::ZERO); // During processing - just write self.modulation_values[block_id.0] = value; }
Connection Lookups
#![allow(unused)] fn main() { // Computed during prepare self.block_input_buffers = vec![Vec::new(); self.blocks.len()]; for conn in &self.connections { self.block_input_buffers[conn.to.0].push(buffer_idx); } // During processing - O(1) read let inputs = &self.block_input_buffers[block_id.0]; }
Stack Allocation
Temporary collections use stack memory:
#![allow(unused)] fn main() { const MAX_BLOCK_INPUTS: usize = 8; // No heap allocation let mut input_slices: StackVec<&[S], MAX_BLOCK_INPUTS> = StackVec::new(); }
Verification
Check with a global allocator hook:
#![allow(unused)] fn main() { #[cfg(test)] #[global_allocator] static ALLOC: dhat::Alloc = dhat::Alloc; #[test] fn test_no_allocations_during_process() { // Setup... let before = dhat::total_allocations(); graph.process_buffers(&mut outputs); let after = dhat::total_allocations(); assert_eq!(before, after, "Allocations during process!"); } }
Cache Efficiency
Optimizing for CPU cache performance.
Cache Hierarchy
| Level | Size | Latency |
|---|---|---|
| L1 | 32-64 KB | ~4 cycles |
| L2 | 256-512 KB | ~12 cycles |
| L3 | 4-32 MB | ~40 cycles |
| RAM | GBs | ~200+ cycles |
Strategies
Contiguous Storage
Keep related data together:
#![allow(unused)] fn main() { // Good: Single contiguous allocation audio_buffers: Vec<AudioBuffer<S>> // Each buffer is contiguous samples: [S; BUFFER_SIZE] }
Sequential Access
Process in order:
#![allow(unused)] fn main() { // Good: Sequential iteration for sample in buffer.iter_mut() { *sample = process(*sample); } // Avoid: Random access for i in random_order { buffer[i] = process(buffer[i]); } }
Hot/Cold Separation
Separate frequently from rarely used data:
#![allow(unused)] fn main() { struct Block<S> { // Hot path (processing) state: S, coefficient: S, // Cold path (setup) name: String, // Rarely accessed } }
Avoid Pointer Chasing
Minimize indirection:
#![allow(unused)] fn main() { // Less ideal: Vec of trait objects blocks: Vec<Box<dyn Block>> // Better: Enum of concrete types blocks: Vec<BlockType<S>> }
Buffer Layout
Interleaved vs non-interleaved:
#![allow(unused)] fn main() { // Non-interleaved (better for processing) left: [L0, L1, L2, L3, ...] right: [R0, R1, R2, R3, ...] // Interleaved (worse for SIMD) data: [L0, R0, L1, R1, L2, R2, ...] }
bbx_audio uses non-interleaved buffers.
SIMD Optimizations
SIMD (Single Instruction Multiple Data) support for accelerated DSP processing.
Enabling SIMD
Enable the simd feature flag in your Cargo.toml:
[dependencies]
bbx_dsp = { version = "...", features = ["simd"] }
Requirements:
- Nightly Rust toolchain (uses the unstable
portable_simdfeature) - Build with:
cargo +nightly build --features simd
How It Works
SIMD processes multiple samples simultaneously:
Scalar: a[0]*b[0], a[1]*b[1], a[2]*b[2], a[3]*b[3] (4 operations)
SIMD: a[0:3] * b[0:3] (1 operation)
The implementation uses 4-lane vectors (f32x4 and f64x4) from Rust's std::simd.
SIMD Operations
The bbx_core::simd module provides these vectorized operations:
| Function | Description |
|---|---|
fill_f32/f64 | Fill a buffer with a constant value |
apply_gain_f32/f64 | Multiply samples by a gain factor |
multiply_add_f32/f64 | Element-wise multiplication of two buffers |
sin_f32/f64 | Vectorized sine computation |
Additionally, the denormal module provides SIMD-accelerated batch denormal flushing:
flush_denormals_f32_batchflush_denormals_f64_batch
Sample Trait SIMD Methods
The Sample trait includes built-in SIMD support when the simd feature is enabled. This allows writing generic SIMD code that works for both f32 and f64.
Associated Type
Each Sample implementation has an associated SIMD type:
| Sample Type | SIMD Type |
|---|---|
f32 | f32x4 |
f64 | f64x4 |
SIMD Methods
| Method | Description |
|---|---|
simd_splat(value) | Create a vector with all lanes set to value |
simd_from_slice(slice) | Load 4 samples from a slice |
simd_to_array(simd) | Convert a SIMD vector to [Self; 4] |
simd_select_gt(a, b, if_true, if_false) | Per-lane selection where a > b |
simd_select_lt(a, b, if_true, if_false) | Per-lane selection where a < b |
Example: Generic SIMD Code
#![allow(unused)] fn main() { use bbx_core::sample::{Sample, SIMD_LANES}; fn apply_gain_simd<S: Sample>(output: &mut [S], gain: S) { let gain_vec = S::simd_splat(gain); let (chunks, remainder) = output.as_chunks_mut::<SIMD_LANES>(); for chunk in chunks { let samples = S::simd_from_slice(chunk); let result = samples * gain_vec; chunk.copy_from_slice(&S::simd_to_array(result)); } // Scalar fallback for remainder for sample in remainder { *sample = *sample * gain; } } }
This single implementation works for both f32 and f64 without code duplication.
Optimized Blocks
The following blocks use SIMD when the feature is enabled:
| Block | Optimization |
|---|---|
OscillatorBlock | Vectorized waveform generation (4 samples at a time) |
LfoBlock | Vectorized modulation signal generation |
GainBlock | Vectorized gain application |
PannerBlock | Vectorized sin/cos gain calculation |
Feature Propagation
The simd feature propagates through crate dependencies:
bbx_plugin --simd--> bbx_dsp --simd--> bbx_core
Enable simd on bbx_plugin for plugin builds:
[dependencies]
bbx_plugin = { version = "...", features = ["simd"] }
Trade-offs
| Aspect | Scalar | SIMD |
|---|---|---|
| Complexity | Simple | More complex |
| Toolchain | Stable Rust | Nightly required |
| Debugging | Easy | Harder |
| Performance | Baseline | Up to 4x faster |
Implementation Notes
- Lane width is 4 for both
f32andf64(SSE/NEON compatible) - Remainder samples (when buffer size isn't divisible by 4) are processed with scalar fallback
- Noise waveforms use scalar processing due to RNG sequentiality requirements
Development Setup
Set up your environment for contributing to bbx_audio.
Prerequisites
Rust Toolchain
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Add nightly toolchain (required for fmt and clippy)
rustup toolchain install nightly
Platform Dependencies
Linux:
sudo apt install alsa libasound2-dev libssl-dev pkg-config
macOS/Windows: No additional dependencies.
Clone and Build
# Clone repository
git clone https://github.com/blackboxaudio/bbx_audio.git
cd bbx_audio
# Build all crates
cargo build --workspace
# Run tests
cargo test --workspace --release
IDE Setup
VS Code
Recommended extensions:
- rust-analyzer
- Even Better TOML
- CodeLLDB (debugging)
Settings (.vscode/settings.json):
{
"rust-analyzer.cargo.features": "all",
"rust-analyzer.check.command": "clippy"
}
Other IDEs
- IntelliJ IDEA - Use Rust plugin
- Vim/Neovim - Use rust-analyzer LSP
- Emacs - Use rustic-mode
Development Workflow
# Format code
cargo +nightly fmt
# Run lints
cargo +nightly clippy
# Run tests
cargo test --workspace --release
# Run an example
cargo run --release --example 01_sine_wave -p bbx_sandbox
Common Tasks
Adding a New Block
- Create block in appropriate
blocks/subdirectory - Add to
BlockTypeenum - Add builder method to
GraphBuilder - Write tests
- Update documentation
Modifying FFI
- Update Rust code in
bbx_plugin - Regenerate header with cbindgen
- Update
bbx_graph.hif needed - Test with JUCE project
Code Style
Coding conventions for bbx_audio.
Formatting
Use nightly rustfmt:
cargo +nightly fmt
Configuration is in rustfmt.toml.
Linting
Use nightly clippy:
cargo +nightly clippy
Fix all warnings before submitting.
Naming Conventions
Types
- Structs: PascalCase (
OscillatorBlock) - Enums: PascalCase (
Waveform) - Traits: PascalCase (
Block,Sample)
Functions and Methods
- Functions: snake_case (
process_buffers) - Methods: snake_case (
add,connect) - Constructors:
new()orfrom_*()/with_*()
Variables
- Local: snake_case (
buffer_size) - Constants: SCREAMING_SNAKE_CASE (
MAX_BLOCK_INPUTS)
Documentation
Public Items
All public items must have documentation:
#![allow(unused)] fn main() { /// A block that generates waveforms. /// /// # Example /// /// ``` /// let osc = OscillatorBlock::new(440.0, Waveform::Sine); /// ``` pub struct OscillatorBlock<S: Sample> { // ... } }
Module Documentation
Each module should have a top-level doc comment:
#![allow(unused)] fn main() { //! DSP graph system. //! //! This module provides [`Graph`] for managing connected DSP blocks. }
Safety Comments
All unsafe blocks must have a SAFETY: comment:
#![allow(unused)] fn main() { // SAFETY: The buffer indices are pre-computed and validated // during prepare_for_playback(), guaranteeing valid access. unsafe { let slice = std::slice::from_raw_parts(ptr, len); } }
Error Handling
- Use
Result<T, BbxError>for fallible operations - Use
Option<T>for optional values - Avoid
unwrap()in library code - Use
expect()with clear messages for invariants
Testing
- Unit tests in
#[cfg(test)]modules - Integration tests in
tests/directory - Document test coverage for new features
Adding New Blocks
Guide to implementing new DSP blocks.
Block Structure
Create a new file in the appropriate category:
bbx_dsp/src/blocks/
├── generators/
│ └── my_generator.rs
├── effectors/
│ └── my_effect.rs
└── modulators/
└── my_modulator.rs
Implement the Block Trait
#![allow(unused)] fn main() { use crate::{ block::{Block, DEFAULT_EFFECTOR_INPUT_COUNT, DEFAULT_EFFECTOR_OUTPUT_COUNT}, context::DspContext, parameter::{ModulationOutput, Parameter}, sample::Sample, }; const MAX_BUFFER_SIZE: usize = 4096; pub struct MyEffectBlock<S: Sample> { pub gain: Parameter<S>, state: S, } impl<S: Sample> MyEffectBlock<S> { pub fn new(gain: f64) -> Self { Self { gain: Parameter::Constant(S::from_f64(gain)), state: S::ZERO, } } } impl<S: Sample> Block<S> for MyEffectBlock<S> { fn prepare(&mut self, context: &DspContext) { // Initialize parameter smoothing with sample rate self.gain.prepare(context.sample_rate); } fn process( &mut self, inputs: &[&[S]], outputs: &mut [&mut [S]], modulation_values: &[S], context: &DspContext, ) { // Update smoothing target from modulation source self.gain.update_target(modulation_values); let len = inputs.first().map_or(0, |ch| ch.len().min(context.buffer_size)); let num_channels = inputs.len().min(outputs.len()); // Fast path: constant value when not smoothing if !self.gain.is_smoothing() { let gain = self.gain.current(); for ch in 0..num_channels { for i in 0..len { outputs[ch][i] = inputs[ch][i] * gain; } } return; } // Smoothing path: pre-compute smoothed values once let mut gain_values: [S; MAX_BUFFER_SIZE] = [S::ZERO; MAX_BUFFER_SIZE]; for gain_value in gain_values.iter_mut().take(len) { *gain_value = self.gain.next_value(); } // Apply to all channels for ch in 0..num_channels { for (i, &gain) in gain_values.iter().enumerate().take(len) { outputs[ch][i] = inputs[ch][i] * gain; } } } fn input_count(&self) -> usize { DEFAULT_EFFECTOR_INPUT_COUNT } fn output_count(&self) -> usize { DEFAULT_EFFECTOR_OUTPUT_COUNT } fn modulation_outputs(&self) -> &[ModulationOutput] { &[] } fn reset(&mut self) { self.state = S::ZERO; } } }
Key Patterns
Parameter Initialization
Parameters combine value source with built-in smoothing:
#![allow(unused)] fn main() { // Constant parameter (50ms default ramp) let gain = Parameter::Constant(S::from_f64(0.5)); // Custom ramp time let freq = Parameter::Constant(S::from_f64(440.0)).with_ramp_ms(100.0); }
Processing Flow
prepare(): Callparameter.prepare(sample_rate)to initialize smoothing- Update target: Use
update_target()orset_target()at buffer start - Check smoothing: Use
is_smoothing()for fast-path optimization - Get values: Use
current()for constant,next_value()for smoothing
Value Transforms
When the raw value needs transformation before smoothing (e.g., dB to linear):
#![allow(unused)] fn main() { // Get raw dB value let db = self.level_db.get_raw_value(modulation_values).to_f64(); // Convert to linear and set as smooth target let linear = 10.0_f64.powf(db / 20.0); self.level_db.set_target(S::from_f64(linear)); }
Add to BlockType
In bbx_dsp/src/block.rs:
#![allow(unused)] fn main() { pub enum BlockType<S: Sample> { // Existing variants... MyEffect(MyEffectBlock<S>), } }
Update all match arms in BlockType's Block implementation.
Add Builder Method
In bbx_dsp/src/graph.rs:
#![allow(unused)] fn main() { impl<S: Sample> GraphBuilder<S> { pub fn add_my_effect(&mut self, gain: f64) -> BlockId { let block = BlockType::MyEffect(MyEffectBlock::new(gain)); self.graph.add_block(block) } } }
Write Tests
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_my_effect_basic() { let mut block = MyEffectBlock::<f32>::new(0.5); let context = DspContext::new(44100.0, 4, 1); block.prepare(&context); let input = [1.0, 0.5, 0.25, 0.0]; let mut output = [0.0; 4]; block.process(&[&input], &mut [&mut output], &[], &context); assert_eq!(output, [0.5, 0.25, 0.125, 0.0]); } } }
Update Documentation
- Add to blocks reference in docs
- Update README if significant
- Add examples in bbx_sandbox
Testing
Testing strategies for bbx_audio.
Running Tests
# All tests
cargo test --workspace --release
# Specific crate
cargo test -p bbx_dsp --release
# Specific test
cargo test test_oscillator --release
Test Categories
Unit Tests
In-module tests for individual components:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_gain_calculation() { let block = GainBlock::<f32>::new(-6.0); let expected = 0.5; // -6 dB ≈ 0.5 assert!((block.multiplier() - expected).abs() < 0.01); } } }
Integration Tests
Cross-module tests in tests/ directory:
#![allow(unused)] fn main() { // tests/graph_tests.rs use bbx_dsp::{blocks::OscillatorBlock, graph::GraphBuilder, waveform::Waveform}; #[test] fn test_simple_graph() { let mut builder = GraphBuilder::<f32>::new(44100.0, 512, 2); let osc = builder.add(OscillatorBlock::new(440.0, Waveform::Sine, None)); let graph = builder.build(); // Test processing... } }
Audio Testing Challenges
Audio tests are inherently approximate:
#![allow(unused)] fn main() { #[test] fn test_sine_output() { // Generate one cycle of 1 Hz at 4 samples/second // Expected: [0, 1, 0, -1] let output = generate_sine(4); // Use epsilon comparison assert!((output[0] - 0.0).abs() < 0.001); assert!((output[1] - 1.0).abs() < 0.001); } }
Test Utilities
DspContext for Tests
#![allow(unused)] fn main() { fn test_context() -> DspContext { DspContext::new(44100.0, 512, 2) } }
Buffer Helpers
#![allow(unused)] fn main() { fn create_test_buffers(size: usize) -> (Vec<f32>, Vec<f32>) { (vec![0.0; size], vec![0.0; size]) } }
What to Test
- Edge cases - Zero input, maximum values
- Parameter ranges - Valid and invalid values
- Sample types - Both f32 and f64
- Processing correctness - Expected output values
- State management - Reset behavior
Benchmarking
Performance benchmarks for measuring SIMD optimization effectiveness and overall DSP performance.
Overview
The bbx_dsp crate includes Criterion benchmarks for:
- Block micro-benchmarks - Individual SIMD-optimized blocks in isolation
- Graph integration benchmarks - Realistic DSP graph configurations
Benchmarks support comparing scalar vs SIMD performance by running with and without the simd feature flag.
Available Benchmark Suites
simd_blocks
Micro-benchmarks for individual blocks:
| Block | What's measured | Variations |
|---|---|---|
| OscillatorBlock | Waveform generation | sine, sawtooth, square, triangle |
| PannerBlock | Pan law + gain application | - |
| GainBlock | SIMD gain application | - |
| LfoBlock | Modulation signal generation | sine |
Each block is benchmarked with:
- Sample types: f32, f64
- Buffer sizes: 256, 512, 1024
simd_graphs
Integration benchmarks for realistic DSP configurations:
| Graph | Blocks | Purpose |
|---|---|---|
| simple_chain | Oscillator | Single-block baseline |
| effect_chain | Oscillator → Overdrive | Signal chain overhead |
| modulated_synth | Oscillator + LFO | Modulation path |
| multi_osc | 4 Oscillators | Multiple generator load |
Running Benchmarks
Basic Commands
# Run all benchmarks (scalar mode)
cargo bench -p bbx_dsp
# Run all benchmarks (SIMD mode, requires nightly)
cargo +nightly bench -p bbx_dsp --features simd
# Run specific benchmark suite
cargo bench -p bbx_dsp --bench simd_blocks
cargo bench -p bbx_dsp --bench simd_graphs
# Run specific benchmark by name filter
cargo bench -p bbx_dsp -- oscillator
cargo bench -p bbx_dsp -- "graph_simple"
Comparing SIMD vs Scalar Performance
The recommended workflow for comparing performance:
# 1. Run scalar benchmarks and save as baseline
cargo bench --benches -p bbx_dsp -- --save-baseline scalar
# 2. Run SIMD benchmarks and compare against baseline
cargo +nightly bench --benches -p bbx_dsp --features simd -- --save-baseline scalar
This produces output showing the performance change:
oscillator_f32/sine/512
time: [961.30 ns 962.33 ns 964.71 ns]
thrpt: [530.73 Melem/s 532.04 Melem/s 532.61 Melem/s]
change:
time: [-55.337% -53.509% -52.405%] (p = 0.00 < 0.05)
thrpt: [+110.11% +115.10% +123.90%]
Performance has improved.
Understanding Results
Output Format
Criterion reports three values:
- Lower bound - Conservative estimate
- Estimate - Most likely value
- Upper bound - Optimistic estimate
Throughput
Benchmarks report throughput in Melem/s (million elements per second), representing samples processed per second.
HTML Reports
Criterion generates detailed HTML reports in target/criterion/. Open target/criterion/report/index.html to view:
- Time distribution histograms
- Regression analysis
- Comparison charts between runs
Benchmark Naming Convention
Benchmarks follow the pattern:
{category}_{sample_type}/{variant}/{buffer_size}
Examples:
oscillator_f32/sine/512- f32 sine oscillator, 512 samplespanner_f64/1024- f64 panner, 1024 samplesgraph_simple_chain_f32/512- Simple graph, f32, 512 samples
Use these names to filter benchmarks:
# All f32 benchmarks
cargo bench -p bbx_dsp -- f32
# All 512-sample benchmarks
cargo bench -p bbx_dsp -- /512
# All oscillator benchmarks
cargo bench -p bbx_dsp -- oscillator
Adding New Benchmarks
Block Benchmarks
Add to bbx_dsp/benches/simd_blocks.rs:
#![allow(unused)] fn main() { fn bench_my_block<S: Sample>(c: &mut Criterion, type_name: &str) { let mut group = c.benchmark_group(format!("my_block_{}", type_name)); for buffer_size in BUFFER_SIZES { group.throughput(Throughput::Elements(*buffer_size as u64)); let bench_id = BenchmarkId::from_parameter(buffer_size); group.bench_with_input(bench_id, buffer_size, |b, &size| { let context = create_context(size); let mut block = MyBlock::<S>::new(/* params */); let inputs = create_input_buffers::<S>(size, 1); let mut outputs = create_output_buffers::<S>(size, 1); let modulation_values: Vec<S> = vec![]; b.iter(|| { let input_slices = as_input_slices(&inputs); let mut output_slices = as_output_slices(&mut outputs); block.process( black_box(&input_slices), black_box(&mut output_slices), black_box(&modulation_values), black_box(&context), ); }); }); } group.finish(); } }
Graph Benchmarks
Add to bbx_dsp/benches/simd_graphs.rs:
#![allow(unused)] fn main() { fn create_my_graph<S: Sample>(buffer_size: usize) -> Graph<S> { let mut builder = GraphBuilder::new(SAMPLE_RATE, buffer_size, NUM_CHANNELS); // Add blocks and connections builder.build() } fn bench_my_graph_f32(c: &mut Criterion) { bench_graph::<f32, _>(c, "f32", "my_graph", create_my_graph); } }
Tips
- Warm cache: Criterion automatically warms up before measuring
- Stable environment: Close other applications for consistent results
- Multiple runs: Run benchmarks multiple times to verify consistency
- Release mode: Benchmarks always run in release mode (
--releaseis implicit)
Release Process
How to release new versions of bbx_audio.
Prerequisites
- Push access to the repository
CARGO_REGISTRY_TOKENconfigured in GitHub secrets
Version Bump
- Update version in all
Cargo.tomlfiles - Update
CHANGELOG.md - Commit changes
# Example: bumping to 0.2.0
# Edit Cargo.toml files...
git add -A
git commit -m "Bump version to 0.2.0"
Creating a Release
- Create and push a version tag:
git tag v0.2.0
git push origin v0.2.0
- GitHub Actions will:
- Run tests
- Publish to crates.io
- Create GitHub release
Crate Publishing Order
Crates must be published in dependency order:
bbx_core(no dependencies)bbx_midi(no internal dependencies)bbx_dsp(depends on bbx_core)bbx_file(depends on bbx_dsp)bbx_plugin(depends on bbx_dsp)
Manual Publishing
If needed:
cargo publish -p bbx_core
# Wait for crates.io index update (~1 minute)
cargo publish -p bbx_midi
cargo publish -p bbx_dsp
cargo publish -p bbx_file
cargo publish -p bbx_plugin
Troubleshooting
Publish Failure
If publishing fails mid-way:
- Fix the issue
- Bump patch version
- Retry publishing remaining crates
Version Conflicts
Ensure all workspace crates use the same version in dependencies.
See RELEASING.md in the repository root for detailed instructions.
Changelog
All notable changes to bbx_audio.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Added
- mdBook documentation with comprehensive guides
- Denormal handling support for Apple Silicon (AArch64)
ftz-dazfeature flag for flush-to-zero / denormals-are-zero mode
Changed
- Improved parameter initialization with dynamic approach
- GraphBuilder now connects all terminal blocks to output (fixes multi-oscillator graphs)
Fixed
- Removed duplicate parameter index definitions
- Removed unnecessary PhantomData from GainBlock, OverdriveBlock, PannerBlock
[0.1.0] - Initial Release
Added
-
bbx_core: Foundational utilities
- Denormal handling
- SPSC ring buffer
- Stack-allocated vector
- XorShift RNG
- Error types
-
bbx_dsp: DSP graph system
- Block trait and BlockType enum
- Graph and GraphBuilder
- Topological sorting
- Parameter modulation
- Blocks: Oscillator, Gain, Panner, Overdrive, DC Blocker, Channel Router, LFO, Envelope, File I/O, Output
-
bbx_file: Audio file I/O
- WAV reading via wavers
- WAV writing via hound
-
bbx_midi: MIDI utilities
- Message parsing
- Message buffer
- Input streaming
-
bbx_plugin: Plugin integration
- PluginDsp trait
- FFI macro
- Parameter definitions
- C/C++ headers
-
bbx_sandbox: Examples
- Sine wave generation
- PWM synthesis
- Overdrive effect
- WAV file I/O
- MIDI input
For the full changelog, see CHANGELOG.md.
Migration Guide
Upgrading between bbx_audio versions.
0.1.x to 0.2.x
(Placeholder for future breaking changes)
API Changes
When breaking changes occur, they will be documented here with:
- What changed
- Why it changed
- How to update your code
Example Migration
#![allow(unused)] fn main() { // Old API (0.1.x) let graph = GraphBuilder::new(44100.0, 512, 2).build(); // New API (0.2.x) - hypothetical let graph = GraphBuilder::new() .sample_rate(44100.0) .buffer_size(512) .channels(2) .build(); }
General Upgrade Process
- Read the changelog - Understand what changed
- Update dependencies - Bump version in
Cargo.toml - Run tests - Identify breaking changes
- Fix compilation errors - Update API calls
- Test thoroughly - Verify audio output
Deprecation Policy
- Deprecated APIs are marked with
#[deprecated] - Deprecated APIs remain for at least one minor version
- Removal happens in the next major version
Getting Help
If you encounter migration issues:
- Check the changelog
- Search GitHub issues
- Open a new issue if needed
Troubleshooting
Common issues and solutions.
Build Issues
"toolchain not found"
Install the nightly toolchain:
rustup toolchain install nightly
Linux Audio Errors
Install ALSA development packages:
sudo apt install alsa libasound2-dev
Slow Compilation
Use release mode for faster runtime:
cargo build --release
Runtime Issues
No Audio Output
- Check audio device is available
- Verify sample rate matches system
- Ensure output block is connected
Crackling/Glitches
- Increase buffer size
- Check CPU usage
- Avoid allocations in audio thread
- Profile for bottlenecks
Silence
- Verify block connections
- Check gain levels (not -inf dB)
- Ensure
prepare()was called
FFI Issues
"Cannot find -ldsp"
- Ensure Rust crate builds successfully
- Check Corrosion configuration
- Verify
staticlibcrate type
Header Not Found
Verify target_include_directories in CMake:
target_include_directories(${TARGET} PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/dsp/include)
Linking Errors
Check crate type in Cargo.toml:
[lib]
crate-type = ["staticlib"]
Parameter Issues
Parameters Not Updating
- Verify parameter indices match
- Check
apply_parameters()implementation - Ensure JUCE is calling
Process()with params
Wrong Parameter Values
- Verify JSON/code generation sync
- Check parameter count matches
- Debug print received values
Getting Help
- Check GitHub Issues
- Search existing discussions
- Open a new issue with:
- bbx_audio version
- Platform and OS
- Minimal reproduction
- Error messages
Glossary
Audio DSP and bbx_audio terminology.
A
ADSR: Attack-Decay-Sustain-Release. An envelope shape for amplitude or parameter control.
Anti-aliasing: Techniques to prevent aliasing artifacts when generating waveforms with sharp discontinuities. See PolyBLEP, PolyBLAMP.
Audio Thread: The high-priority thread that processes audio. Must be real-time safe.
B
Block: A DSP processing unit in bbx_audio. Implements the Block trait.
Buffer: A fixed-size array of audio samples, typically 256-2048 samples.
Buffer Size: Number of samples processed per audio callback.
C
Control Rate: Updating values once per buffer, not per sample. Used for modulation.
Cycle: An illegal loop in a DSP graph where a block's output feeds back to its input.
D
DAG: Directed Acyclic Graph. The structure of a DSP graph with no cycles.
Denormal: Very small floating-point numbers that cause CPU slowdowns.
DSP: Digital Signal Processing. Mathematical operations on audio samples.
E
Effector: A block that processes audio (gain, filter, distortion).
Envelope: A time-varying control signal, typically ADSR.
F
FFI: Foreign Function Interface. How Rust code is called from C/C++.
G
Generator: A block that creates audio from nothing (oscillator, noise).
Graph: A connected set of DSP blocks with defined signal flow.
L
Latency: Delay between input and output, measured in samples or milliseconds.
LFO: Low-Frequency Oscillator. A slow oscillator for modulation.
M
Modulation: Varying a parameter over time using a control signal.
Modulator: A block that generates control signals (LFO, envelope).
O
Oscillator: A block that generates periodic waveforms.
P
PolyBLAMP: Polynomial Band-Limited rAMP. Anti-aliasing technique for waveforms with slope discontinuities (triangle waves). Applies polynomial corrections near transition points.
PolyBLEP: Polynomial Band-Limited stEP. Anti-aliasing technique for waveforms with step discontinuities (sawtooth, square, pulse waves). Applies polynomial corrections near transition points to reduce aliasing.
Port: An input or output connection point on a block.
R
Real-Time Safe: Code that completes in bounded time without blocking.
S
Sample: A single audio value at a point in time.
Sample Rate: Samples per second, typically 44100 or 48000 Hz.
SIMD: Single Instruction Multiple Data. Processing multiple samples at once.
SPSC: Single-Producer Single-Consumer. A lock-free queue pattern.
T
Topological Sort: Algorithm that orders blocks so dependencies run first.
W
Waveform: The shape of a periodic signal (sine, square, saw, triangle).