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

CrateDescription
bbx_coreError types and foundational utilities
bbx_dspDSP graph system, blocks, and PluginDsp trait
bbx_pluginC FFI bindings for JUCE integration
bbx_fileAudio file I/O (WAV)
bbx_midiMIDI message parsing and streaming
bbx_drawAudio visualization primitives for nannou
bbx_sandboxExamples 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:

  • Block trait: Defines the DSP processing interface with process(), input/output counts, and modulation outputs
  • BlockType enum: Wraps all concrete block implementations (oscillators, effects, I/O, modulators)
  • Graph: Manages block connections, topological sorting for execution order, and buffer allocation
  • GraphBuilder: 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:

  1. Quick Start - Build your first DSP graph
  2. Graph Architecture - Core design patterns
  3. 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:

  1. Quick Start - Get a simple graph running
  2. JUCE Plugin Integration - Full integration guide
  3. Parameter System - Managing plugin parameters

Game Audio Developers

If you're building audio systems for games and want real-time safe DSP in Rust:

  1. Quick Start - Build your first DSP graph
  2. Real-Time Safety - Allocation-free, lock-free processing
  3. Graph Architecture - Efficient audio routing

Experimental Musicians

If you're building instruments, exploring synthesis, or experimenting with sound design:

  1. Terminal Synthesizer - Build a MIDI-controlled synth from scratch
  2. Parameter Modulation - LFOs, envelopes, and modulation routing
  3. MIDI Integration - Connect keyboards and controllers

Generative Artists

If you're creating sound installations, audio visualizations, or experimenting with procedural audio:

  1. Quick Start - Build your first DSP graph
  2. Real-Time Visualization - Visualize audio with nannou
  3. Sketch Discovery - Manage and organize visual sketches

Library Contributors

If you want to contribute to bbx_audio or understand its internals:

  1. Development Setup - Set up your environment
  2. DSP Graph Architecture - Understand the core design
  3. 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 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:

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 GraphBuilder with 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:

For the concepts used in Part 3:

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 GraphBuilder basics 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

This tutorial covers the effect blocks available in bbx_audio.

Prior knowledge: This tutorial assumes familiarity with:

Available Effects

  • GainBlock - Level control in dB
  • PannerBlock - Stereo panning
  • OverdriveBlock - Soft-clipping distortion
  • DcBlockerBlock - DC offset removal
  • ChannelRouterBlock - Channel routing/manipulation
  • ChannelSplitterBlock - Split multi-channel to individual outputs
  • ChannelMergerBlock - Merge individual inputs to multi-channel
  • LowPassFilterBlock - 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.0 dB = unity (no change)
  • -6.0 dB = half amplitude
  • -12.0 dB = quarter amplitude
  • +6.0 dB = 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 left
  • 0.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:

  1. Gain staging - Control levels before distortion
  2. Distortion - Apply saturation
  3. DC blocking - Remove offset after distortion
  4. EQ/Filtering - Shape the tone
  5. Panning - Position in stereo field
  6. Final gain - Set output level

Next Steps

Parameter Modulation with LFOs

This tutorial covers using LFOs and envelopes to modulate block parameters.

Prior knowledge: This tutorial builds on:

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

ParameterTypeRangeDescription
frequencyf640.01 - max*Rate in Hz
depthf640.0 - 1.0Modulation intensity
seedOption<u64>AnyFor 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

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:

Supported Formats

FormatReadWrite
WAVYesYes

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

  1. Buffer size: Use larger buffers for file processing (2048+ samples)
  2. Non-blocking I/O: FileOutputBlock uses non-blocking I/O internally
  3. Memory: Large files are streamed, not loaded entirely into memory
  4. 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

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:

MIDI Message Types

bbx_midi supports standard MIDI messages:

Message TypeDescription
NoteOnKey pressed
NoteOffKey released
ControlChangeCC messages (knobs, sliders)
PitchWheelPitch bend
ProgramChangePreset selection
PolyphonicAftertouchPer-key pressure
ChannelAftertouchChannel-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:

  • MidiBufferProducer for the MIDI input thread
  • MidiBufferConsumer for 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

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:

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

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:

  1. Scans the directory for .rs files
  2. Extracts metadata from doc comments
  3. 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

FieldTypeDescription
nameStringDisplay name (from filename)
descriptionStringFrom doc comments
source_pathPathBufPath to source file
last_modifiedSystemTimeFile 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() or with_cache_dir()
  • Updated on discover(), register(), remove()

Next Steps

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

  • PluginDsp trait - Defines the interface your DSP must implement
  • bbx_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 codes
  • bbx_graph.h - Header-only C++ RAII wrapper class
  • Parameter indices - Generated #define constants matching Rust indices

Quick Start Checklist

For experienced developers, here's the minimal setup:

  1. Add Corrosion submodule: git submodule add https://github.com/corrosion-rs/corrosion.git vendor/corrosion
  2. Create dsp/Cargo.toml with bbx_plugin dependency and crate-type = ["staticlib"]
  3. Copy bbx_ffi.h and bbx_graph.h to dsp/include/
  4. Implement PluginDsp trait and call bbx_plugin_ffi!(YourType)
  5. Add Corrosion to CMakeLists.txt and link dsp to your plugin target
  6. Use bbx::Graph in your AudioProcessor

For detailed guidance, follow the integration steps below.

Integration Steps

  1. Project Setup - Directory structure and prerequisites
  2. Rust Crate Configuration - Set up Cargo.toml and FFI headers
  3. CMake with Corrosion - Configure the build system
  4. Implementing PluginDsp - Write your DSP processing chain
  5. Parameter System - Define and manage plugin parameters
  6. AudioProcessor Integration - Connect to JUCE
  7. Complete Example - Full working reference

Reference Documentation:

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

  1. Rust toolchain - Install from rustup.rs
  2. CMake 3.15+ - For building the plugin
  3. JUCE - Framework for the plugin
  4. 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

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 declarations
  • bbx_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:

  1. Import the necessary types
  2. Define your DSP struct
  3. Implement PluginDsp
  4. 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 staticlib crate 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 instances
  • Send - 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_CASE constants (PARAM_GAIN = 0)
  • JUCE uses lowercase string IDs ("gain") for AudioProcessorValueTreeState

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

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:

TypeDescriptionValue Range
booleanOn/off toggle0.0 = off, 1.0 = on
floatContinuous valuemin to max
choiceDiscrete options0.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

FieldTypeDescription
idstringParameter identifier (uppercase, used in code generation)
namestringDisplay name for UI
typestring"boolean", "float", or "choice"

Boolean Parameters

FieldTypeDescription
defaultValuebooleanDefault state (true or false)

Float Parameters

FieldTypeDescription
minnumberMinimum value
maxnumberMaximum value
defaultValuenumberDefault value
unitstringOptional unit label (e.g., "dB", "Hz", "%")
midpointnumberOptional midpoint for skewed ranges
intervalnumberOptional step interval
fractionDigitsintegerOptional decimal places to display

Choice Parameters

FieldTypeDescription
choicesstring[]Array of option labels
defaultValueIndexintegerIndex 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 (true or false)

Float

#![allow(unused)]
fn main() {
ParamDef::float(id, name, min, max, default)
}
  • id - Parameter identifier
  • name - Display name
  • min - Minimum value
  • max - Maximum value
  • default - Default value

Choice

#![allow(unused)]
fn main() {
ParamDef::choice(id, name, choices, default_index)
}
  • id - Parameter identifier
  • name - Display name
  • choices - Static slice of option labels
  • default_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 exports
  • BbxGraph handle - Opaque pointer type for C
  • BbxError enum - Error codes for FFI returns

C/C++ Side

  • bbx_ffi.h - C header with function declarations
  • bbx_graph.h - C++ RAII wrapper class

Generated Functions

The bbx_plugin_ffi! macro generates these extern "C" functions:

FunctionDescription
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 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;
ErrorValueDescription
BBX_ERROR_OK0Operation succeeded
BBX_ERROR_NULL_POINTER1Handle was NULL
BBX_ERROR_INVALID_PARAMETER2Invalid parameter value
BBX_ERROR_INVALID_BUFFER_SIZE3Buffer size was 0
BBX_ERROR_GRAPH_NOT_PREPARED4Graph not prepared before processing
BBX_ERROR_ALLOCATION_FAILED5Memory 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 with NULL)

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 handle
  • sample_rate - Sample rate in Hz (e.g., 44100.0, 48000.0)
  • buffer_size - Number of samples per buffer
  • num_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 handle
  • inputs - Array of input channel pointers
  • outputs - Array of output channel pointers
  • num_channels - Number of audio channels
  • num_samples - Number of samples per channel
  • params - Pointer to flat array of parameter values
  • num_params - Number of parameters
  • midi_events - Pointer to array of MIDI events (may be NULL for 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:

  1. Store bbx::Graph as a member
  2. Call Prepare() in prepareToPlay()
  3. Call Reset() in releaseResources()
  4. Call Process() in processBlock()

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::Graph is 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

FeatureDescription
SampleGeneric sample type trait with SIMD support
SIMDVectorized DSP operations (feature-gated)
Denormal HandlingFlush denormal floats to zero
SPSC Ring BufferLock-free producer-consumer queue
Stack VectorFixed-capacity heap-free vector
RandomFast XorShift RNG
Error TypesUnified 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 Sample trait is defined in bbx_core and re-exported by bbx_dsp for 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:

ConstantValueCommon DSP Use
PIπ ≈ 3.14159Phase calculations, filter coefficients
TAU2π ≈ 6.28318Full cycle/phase wrap, angular frequency
INV_TAU1/(2π)Frequency-to-phase conversion
FRAC_PI_2π/2Quarter-wave, phase shifts
SQRT_2√2 ≈ 1.414RMS calculations, equal-power panning
INV_SQRT_21/√2 ≈ 0.707Equal-power crossfade, normalization
Ee ≈ 2.718Exponential decay, RC filter time constants
PHIφ ≈ 1.618Golden 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

  • 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

PlatformBehavior
x86/x86_64Full FTZ + DAZ via MXCSR register
AArch64 (ARM64/Apple Silicon)FTZ only via FPCR register
OtherNo-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

ApproachUse Case
flush_denormal_* functionsCross-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:

  • Relaxed for capacity checks
  • Acquire/Release for 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

TypeHeap AllocationFixed SizeGrowable
Vec<T>YesNoYes
[T; N]NoYesNo
StackVec<T, N>NoYes (max)Yes (up to N)
ArrayVec<T, N>NoYes (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> vs StackVec<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 enum
  • Result<T> - Type alias for Result<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

FeatureDescription
Visualizer TraitCore trait for all visualizers
Audio BridgeLock-free thread communication
Graph TopologyDSP graph layout display
WaveformOscilloscope-style waveform
SpectrumFFT-based spectrum analyzer
MIDI ActivityPiano 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.

ParameterDescription
drawnannou's Draw API for rendering
boundsRectangle 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

MethodDescription
try_send(&mut self, frame: Frame) -> boolSend a frame (non-blocking)
is_full(&self) -> boolCheck 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

MethodDescription
try_pop(&mut self) -> Option<Frame>Pop one frame (non-blocking)
is_empty(&self) -> boolCheck if buffer is empty
len(&self) -> usizeNumber 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

MethodDescription
try_send(&mut self, msg: MidiMessage) -> boolSend a MIDI message
is_full(&self) -> boolCheck if buffer is full

MidiBridgeConsumer

MethodDescription
drain(&mut self) -> Vec<MidiMessage>Get all available messages
is_empty(&self) -> boolCheck if buffer is empty

Capacity Guidelines

Bridge TypeRecommended CapacityNotes
Audio4-16 framesHigher = more latency
MIDI64-256 messagesMIDI 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:

FieldTypeDescription
samplesStackVec<S, MAX_FRAME_SAMPLES>Audio samples
sample_rateu32Sample rate in Hz
channelsusizeNumber 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

OptionTypeDefaultDescription
block_widthf32120.0Width of block rectangles
block_heightf3250.0Height of block rectangles
horizontal_spacingf3280.0Space between depth columns
vertical_spacingf3230.0Space between blocks in column

Colors

OptionTypeDefaultDescription
generator_colorRgbBlueGenerator block fill
effector_colorRgbGreenEffector block fill
modulator_colorRgbPurpleModulator block fill
io_colorRgbOrangeI/O block fill
audio_connection_colorRgbGrayAudio connection lines
modulation_connection_colorRgbPinkModulation connection lines
text_colorRgbWhiteBlock label text

Connections

OptionTypeDefaultDescription
audio_connection_weightf322.0Audio line thickness
modulation_connection_weightf321.5Modulation line thickness
show_arrowsbooltrueShow directional arrows
arrow_sizef328.0Arrow head size
dash_lengthf328.0Modulation dash length
dash_gapf324.0Gap between dashes

Layout Algorithm

Blocks are positioned using topological depth:

  1. Source blocks (no inputs) have depth 0
  2. Each block's depth is max(input depths) + 1
  3. Blocks are arranged left-to-right by depth
  4. 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

FeatureDescription
GraphBlock graph and builder
Block TraitInterface for DSP blocks
BlockTypeEnum wrapping all blocks
SampleRe-exported from bbx_core
DspContextProcessing context
ParametersModulation 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 control
  • PannerBlock - Stereo panning
  • OverdriveBlock - Distortion
  • DcBlockerBlock - DC removal
  • ChannelRouterBlock - Channel routing

Modulators

Blocks that generate control signals:

  • LfoBlock - Low-frequency oscillator
  • EnvelopeBlock - ADSR envelope

I/O

Blocks for input/output:

  • FileInputBlock - Audio file input
  • FileOutputBlock - Audio file output
  • OutputBlock - Graph output

Architecture

The DSP system uses a pull model:

  1. GraphBuilder collects blocks and connections
  2. build() creates an optimized Graph
  3. Topological sorting determines execution order
  4. 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:

  1. Validates all connections
  2. Performs topological sorting
  3. Allocates processing buffers
  4. 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 buffers
  • outputs - Mutable slice of output channel buffers
  • modulation_values - Values from connected modulator blocks
  • context - 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:

  1. Implement Block<S> for your block
  2. Add a variant to BlockType
  3. Update all match arms in BlockType's Block implementation
  4. 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:

CategoryVariants
GeneratorsOscillator
EffectorsChannelRouter, DcBlocker, Gain, LowPassFilter, Overdrive, Panner, Vca
ModulatorsEnvelope, Lfo
I/OFileInput, 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):

  1. prepare() is called on all blocks
  2. Blocks should recalculate time-dependent values
  3. 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

  1. Modulator blocks (LFO, Envelope) output control values
  2. These values are collected during graph processing
  3. Target blocks receive values in the modulation_values parameter
#![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_dsp for single-dependency usage
  • PluginDsp trait for plugin DSP implementations
  • bbx_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

FeatureDescription
PluginDsp TraitInterface for plugin DSP
FFI MacroGenerate C exports
Parameter DefinitionsJSON 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 instantiation
  • Send - 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:

  • AtomicF32 for 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:

  1. JSON-based - Parse parameters.json
  2. 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

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

FormatReadWrite
WAVYesYes

Features

FeatureDescription
WAV ReaderLoad WAV files
WAV WriterCreate 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

FeatureDescription
MIDI MessagesMessage parsing and types
Lock-Free BufferThread-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

StatusDescription
NoteOnKey pressed
NoteOffKey released
ControlChangeCC (knobs, pedals)
PitchWheelPitch bend
ProgramChangePreset change
PolyphonicAftertouchPer-note pressure
ChannelAftertouchChannel 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 messages
  • MidiBufferConsumer - 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

  • MidiBufferProducer and MidiBufferConsumer are Send but not Sync
  • Each should be owned by exactly one thread
  • The underlying SPSC ring buffer handles synchronization
  • All consumer operations are wait-free

See Also

Generators

Generator blocks create audio signals from nothing.

Available Generators

BlockDescription
OscillatorBlockWaveform 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.

WaveformFormulaOutput 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:

WaveformHarmonics PresentAmplitude Falloff
SineFundamental only
SquareOdd (1, 3, 5, 7...)$\frac{1}{n}$
SawtoothAll (1, 2, 3, 4...)$\frac{1}{n}$
TriangleOdd (1, 3, 5, 7...)$\frac{1}{n^2}$
PulseAllVaries 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

WaveformHarmonicsCharacter
SineFundamental onlyPure, clean
SquareOdd harmonicsHollow, woody
SawAll harmonicsBright, buzzy
TriangleOdd (weak)Soft, flute-like
PulseVariableNasal, reedy
NoiseAll frequenciesAiry, percussive

Parameters

ParameterTypeRangeDefaultDescription
frequencyf640.01 - 20000 Hz440Base frequency
pitch_offsetf64-24 to +24 semitones0Pitch offset from base

Both parameters can be modulated using the modulate() method.

Port Layout

PortDirectionDescription
0OutputAudio 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

BlockDescription
GainBlockLevel control in dB
VcaBlockVoltage controlled amplifier
PannerBlockStereo, surround (VBAP), and ambisonic panning
OverdriveBlockSoft-clipping distortion
DcBlockerBlockDC offset removal
ChannelRouterBlockSimple stereo channel routing
ChannelSplitterBlockSplit multi-channel to mono outputs
ChannelMergerBlockMerge mono inputs to multi-channel
MatrixMixerBlockNxM mixing matrix
MixerBlockChannel-wise audio mixer
AmbisonicDecoderBlockAmbisonics B-format decoder
BinauralDecoderBlockB-format to stereo binaural
LowPassFilterBlockSVF 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 ChangePerceived EffectLinear Multiplier
+20 dB4× louder10.0
+10 dB2× louder3.16
+6 dBNoticeably louder2.0
+3 dBJust noticeably louder1.41
0 dBNo change1.0
-3 dBJust noticeably quieter0.71
-6 dBNoticeably quieter0.5
-10 dBHalf as loud0.316
-20 dBQuarter as loud0.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

dBLinearRelationship
+30 dB31.6Maximum boost in this block
+6 dB2.0Double amplitude
+3 dB1.414Double power
0 dB1.0Unity gain (no change)
-3 dB0.707Half power
-6 dB0.5Half amplitude
-20 dB0.1One-tenth amplitude
-40 dB0.01One-hundredth amplitude
-60 dB0.001Roughly noise floor
-80 dB0.0001Minimum 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

PortDirectionDescription
0InputAudio input
0OutputScaled audio output

Parameters

ParameterTypeRangeDefaultDescription
level_dbf64-80.0 to +30.0 dB0.0Gain level in decibels
base_gainf640.0 to 1.0+1.0Additional linear multiplier

Level Values

dBLinearEffect
+124.04x louder
+62.02x louder
+31.41~1.4x louder
01.0No change
-30.71~0.7x amplitude
-60.5Half amplitude
-120.25Quarter amplitude
-200.110% amplitude
-80~0.0Near 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

PortDirectionDescription
0InputAudio signal
1InputControl signal (0.0 to 1.0)
0OutputModulated 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

FeatureVcaBlockGainBlock
ControlSample-by-sample from inputFixed dB parameter
Use caseEnvelope/LFO modulationStatic level control
Inputs2 (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]$:

  1. Normalize to $[0, 1]$: $$ t = \frac{p + 100}{200} $$

  2. Convert to angle: $$ \alpha = t \cdot \frac{\pi}{2} $$

  3. 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.01.00.01.0
Center (0)0.545°0.7070.7071.0
Full right (+100)1.090°0.01.01.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):

ChannelNameAzimuth
0L (Left)30°
1R (Right)-30°
2C (Center)
3LFE— (omnidirectional)
4Ls (Left Surround)110°
5Rs (Right Surround)-110°

7.1 (ITU-R BS.2051):

ChannelNameAzimuth
0L (Left)30°
1R (Right)-30°
2C (Center)
3LFE
4Ls (Left Side)90°
5Rs (Right Side)-90°
6Lrs (Left Rear)150°
7Rrs (Right Rear)-150°

Gain Calculation

For a source at azimuth $\theta_s$ and speaker $i$ at azimuth $\theta_i$:

  1. Calculate angular difference (handling wrap-around): $$ \Delta\theta_i = \min(|\theta_s - \theta_i|, 2\pi - |\theta_s - \theta_i|) $$

  2. 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} $$

  3. 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:

ModeInputsOutputs
Stereo12
Surround 5.116
Surround 7.118
Ambisonic FOA14
Ambisonic SOA19
Ambisonic TOA116

Parameters

ParameterTypeRangeModeDefault
positionf32-100.0 to 100.0Stereo0.0
azimuthf32-180.0 to 180.0Surround, Ambisonic0.0
elevationf32-90.0 to 90.0Surround, Ambisonic0.0

Position Values (Stereo)

ValuePosition
-100.0Hard left
-50.0Half left
0.0Center
+50.0Half right
+100.0Hard right

Azimuth/Elevation (Surround/Ambisonic)

AzimuthDirection
0Front
90Left
-90Right
180 / -180Rear
ElevationDirection
0Horizon
90Above
-90Below

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 TypeHarmonics GeneratedCharacter
Symmetric softOdd only (3rd, 5th, 7th...)Clean, tube-like
Asymmetric softOdd + Even (2nd, 3rd, 4th...)Warm, organic
Hard clipMany high-orderHarsh, 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

PortDirectionDescription
0InputAudio input
0OutputDistorted audio

Parameters

ParameterTypeRangeDefaultDescription
drivef641.0 - 10.0+1.0Input gain before clipping
levelf640.0 - 1.01.0Output level
tonef640.0 - 1.00.5Brightness (0=dark, 1=bright)

Drive Values

DriveCharacter
1.0Subtle warmth
3.0Moderate saturation
5.0Heavy 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:

  1. Reduced headroom: Asymmetric clipping occurs sooner
  2. Speaker damage: DC current heats voice coils
  3. Incorrect metering: Peak meters show false values
  4. 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

PortDirectionDescription
0InputAudio with DC
0OutputDC-free audio

Parameters

ParameterTypeDefaultDescription
enabledbooltrueEnable/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

ParameterTypeDescription
modeChannelModeChannel routing mode
monoboolSum to mono (L+R)/2 on both channels
invert_leftboolInvert left channel phase
invert_rightboolInvert 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

ModeLeft OutRight Out
StereoLeft InRight In
LeftLeft InLeft In
RightRight InRight In
SwapRight InLeft In

Port Layout

PortDirectionDescription
0InputLeft channel
1InputRight channel
0OutputLeft channel
1OutputRight 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

PortDirectionDescription
0..NInputMulti-channel input
0..NOutputIndividual mono outputs

Input and output counts are equal, determined by the channels parameter (1-16).

Parameters

ParameterTypeRangeDescription
channelsusize1-16Number 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 channels is 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

PortDirectionDescription
0..NInputIndividual mono inputs
0..NOutputMulti-channel output

Input and output counts are equal, determined by the channels parameter (1-16).

Parameters

ParameterTypeRangeDescription
channelsusize1-16Number 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 channels is 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

PortDirectionDescription
0..NInputN input channels
0..MOutputM output channels

Input and output counts are configured independently (1-16 each).

Parameters

ParameterTypeRangeDescription
inputsusize1-16Number of input channels
outputsusize1-16Number 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 inputs or outputs is 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.

SourcesNormalization FactordB Reduction
20.5-6 dB
30.333-9.5 dB
40.25-12 dB
80.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] $$

SourcesNormalization FactordB Reduction
20.707-3 dB
30.577-4.8 dB
40.5-6 dB
80.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:

ConfigurationInputsOutputs
stereo(2)42
stereo(3)62
stereo(4)82
new(3, 1) (mono)31
new(2, 6) (5.1)126

Maximum total inputs: 16 (constrained by MAX_BLOCK_INPUTS)

Parameters

ParameterTypeRangeDefault
num_sourcesusize1-8-
num_channelsusize1-16-
normalizationenumAverage, ConstantPowerConstantPower

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 ConstantPower for natural-sounding mixes
  • Panics if num_sources or num_channels is 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

OrderChannelsFormat
1 (FOA)4W, Y, Z, X
2 (SOA)9W, Y, Z, X, V, T, R, S, U
3 (TOA)16Full third-order

Output Layouts

LayoutChannelsDescription
Mono1Single speaker
Stereo2L, R at +/-30 degrees
Surround516L, R, C, LFE, Ls, Rs
Surround718L, R, C, LFE, Ls, Rs, Lrs, Rrs
Custom(n)nCircular array

Port Layout

PortDirectionDescription
0..NInputAmbisonic channels (4/9/16)
0..MOutputSpeaker feeds

Input count depends on order: (order + 1)^2 Output count depends on the target layout.

Speaker Positions

Stereo

ChannelAzimuth
Left+30 degrees
Right-30 degrees

5.1 Surround

ChannelAzimuth
L+30 degrees
R-30 degrees
C0 degrees
LFEN/A (omnidirectional)
Ls+110 degrees
Rs-110 degrees

7.1 Surround

ChannelAzimuth
L+30 degrees
R-30 degrees
C0 degrees
LFEN/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

OrderChannelsFormat
1 (FOA)4W, Y, Z, X
2 (SOA)9W, Y, Z, X, V, T, R, S, U
3 (TOA)16Full third-order

Surround Input

LayoutChannelsDescription
5.16L, R, C, LFE, Ls, Rs
7.18L, R, C, LFE, Ls, Rs, Lrs, Rrs

Output

Always stereo (2 channels): Left ear, Right ear.

Port Layout

PortDirectionDescription
0..NInputMulti-channel audio (4/6/8/9/16)
0OutputLeft ear
1OutputRight 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:

  1. Virtual speaker layout: 4 speakers at ±45° and ±135° azimuth for FOA
  2. Spherical harmonic decoding: Input channels weighted by SH coefficients for each speaker position
  3. HRIR convolution: Each speaker signal convolved with position-specific HRIRs
  4. 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

StrategyCPU UsageSpatial AccuracyExternalization
HRTFHigherExcellentYes
MatrixLowerBasicLimited

HRTF complexity: O(samples × speakers × hrir_length) per buffer.

See Also

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 ValueCharacterPeak at Cutoff
0.5Heavily damped, gradual rolloffNo peak
0.707Butterworth (maximally flat passband)No peak
1.0Slight resonanceSmall peak
2.0+Pronounced resonanceNoticeable peak
10.0Near self-oscillationLarge 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:

  1. Preserves the analog frequency response shape
  2. Avoids the "cramping" effect near Nyquist
  3. 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

PortDirectionDescription
0InputAudio input
0OutputFiltered audio

Parameters

ParameterTypeRangeDefault
Cutofff6420-20000 Hz-
Resonancef640.5-10.00.707

Resonance Values

ValueCharacter
0.5Heavily damped
0.707Butterworth (flat)
1.0Slight peak
2.0+Pronounced peak
10.0Near 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

BlockDescription
LfoBlockLow-frequency oscillator
EnvelopeBlockADSR 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

AspectAudioModulation
RateSample ratePer-block (control rate)
Range-1.0 to 1.0-1.0 to 1.0
PurposeListeningParameter 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:

BlockParameterEffect
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

EffectTarget ParameterTypical RateTypical Depth
VibratoPitch/Frequency4-7 Hz0.1-0.5
TremoloAmplitude/Gain4-10 Hz0.3-1.0
Auto-panPan position0.1-1 Hz0.5-1.0
Filter sweepFilter cutoff0.05-2 Hz0.3-0.8
Wobble bassFilter cutoff1-4 Hz0.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

PortDirectionDescription
0Modulation OutputControl signal (-depth to +depth)

Parameters

ParameterTypeRangeDefaultDescription
frequencyf640.01 - ~43 Hz1.0Oscillation rate
depthf640.0 - 1.01.0Output amplitude
waveformWaveform-SineShape of modulation
seedOption<u64>AnyNoneRandom seed (for Noise)

Waveforms

WaveformCharacterUse Case
SineSmooth, naturalVibrato, tremolo
TriangleLinear, symmetricPitch wobble
SawtoothRising rampFilter sweeps
SquareAbrupt on/offGated effects
NoiseRandomOrganic 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

ApplicationRate RangeNotes
Vibrato4-7 HzNatural vocal/string range
Tremolo4-10 HzFaster = more intense
Auto-pan0.1-1 HzSlower = more subtle
Filter wobble1-4 HzDubstep/bass music
Slow evolution0.01-0.1 HzPad 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:

  1. Attack: Signal rises from 0 to peak (1.0)
  2. Decay: Signal falls from peak to sustain level
  3. Sustain: Signal holds at a fixed level while key is held
  4. 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

PortDirectionDescription
0OutputEnvelope value (0.0 to 1.0)

Parameters

ParameterTypeRangeDefaultDescription
attackf640.001 - 10.0 s0.01Time to reach peak
decayf640.001 - 10.0 s0.1Time to reach sustain
sustainf640.0 - 1.00.5Hold level (fraction of peak)
releasef640.001 - 10.0 s0.3Time to reach zero

Time values are clamped to [0.001, 10.0] seconds to prevent numerical issues.

Typical Settings

Sound TypeAttackDecaySustainRelease
Pluck/Stab0.0010.10.00.2
Piano0.0010.50.30.5
Pad0.50.20.81.0
Organ0.0010.01.00.1
Brass0.050.10.80.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

BlockDescription
FileInputBlockRead from audio files
FileOutputBlockWrite to audio files
OutputBlockGraph 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

PortDirectionDescription
0OutputLeft channel
1OutputRight channel (if stereo)
NOutputChannel 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

PortDirectionDescription
0InputLeft channel
1InputRight channel (if stereo)
NInputChannel 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

PortDirectionDescription
0InputLeft channel
1InputRight channel
NInputChannel 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:

  1. Blocks are processing nodes
  2. Connections define signal flow
  3. Topological sorting determines execution order
  4. 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

  1. Clear buffers - Zero all audio buffers
  2. Execute blocks - Process in topological order
  3. Collect modulation - Gather modulator outputs
  4. 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
}
}

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, &degree) 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

  1. Calculate in-degrees: Count incoming connections for each block
  2. Initialize queue: Add blocks with no inputs (sources)
  3. Process queue:
    • Remove a block from queue
    • Add to result
    • Decrement in-degree of connected blocks
    • Add newly zero-degree blocks to queue
  4. 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:

  1. Queue: [0, 3] (in-degree 0)
  2. Pop 0, result: [0], decrement block 1
  3. Pop 3, result: [0, 3], decrement block 1 (now 0)
  4. Queue: [1], pop 1, result: [0, 3, 1], decrement block 2 (now 0)
  5. 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:

  1. Buffers start zeroed
  2. Each source adds to the buffer
  3. 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

LayoutChannelsDescription
Mono1Single channel
Stereo2Left, Right
Surround5165.1 surround
Surround7187.1 surround
AmbisonicFoa4First-order ambisonics
AmbisonicSoa9Second-order ambisonics
AmbisonicToa16Third-order ambisonics
Custom(n)nUser-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:

BlockInputsOutputsPurpose
PannerBlock12-16Position mono source in spatial field
ChannelSplitterBlockNNSeparate channels for individual processing
ChannelMergerBlockNNCombine channels back together
MatrixMixerBlockNMArbitrary NxM mixing with gain control
AmbisonicDecoderBlock4/9/162-8Decode ambisonics to speakers
ChannelRouterBlock22Simple 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:

  1. Decode ambisonic input to $N$ virtual speaker signals using SH coefficients
  2. Convolve each speaker signal with position-specific HRIRs
  3. 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):

ChannelAzimuth
L/R$\pm 30°$
C$0°$
LFE$0°$ (non-directional)
Ls/Rs$\pm 110°$

7.1 (ITU-R BS.2051):

ChannelAzimuth
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

PlatformBehavior
x86/x86_64Full FTZ + DAZ (inputs and outputs flushed)
AArch64 (ARM64/Apple Silicon)FTZ only (outputs flushed)
OtherNo-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.

ProsCons
No per-sample overheadAffects all code on the thread
Handles all float operations automaticallyARM: 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:

ThreadPriorityDeadlineBlocking
AudioReal-timeFixed (e.g., 5.8ms at 44.1kHz/256 samples)Never allowed
VisualizationBest-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 false if 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 None if 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:

SourceRateData Per Visual Frame
Audio (44.1kHz, 512 samples)~86 frames/sec~1.4 frames
Visualization60 frames/sec1 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);
}
CapacityLatencyReliability
4~5msMay drop during load
8~10msBalanced
16~20msVery 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:

CapacityNotes
64Light use
256Heavy chords, fast playing
512Recording, 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:

AspectAudio RateControl Rate
UpdatesPer samplePer buffer
CPU costHighLow
LatencyNone1 buffer
PrecisionPerfectGood enough

Most musical modulation is below 20 Hz, well within control rate capabilities.

Modulation Flow

  1. Modulator block processes (LFO, Envelope)
  2. First sample collected as modulation value
  3. Target block receives value in modulation array
  4. 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:

  1. Unifies constant and dynamic - Same API for both
  2. Type-safe modulation - Compile-time block ID checking
  3. Zero-cost constant - No indirection for constant values
  4. 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:

  1. Buffer N: LFO generates sample
  2. Buffer N: Value collected
  3. 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

  1. Safety - Prevent memory errors across language boundary
  2. Simplicity - Minimal API surface
  3. Performance - Zero-copy audio processing
  4. 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:

  1. We control both sides
  2. Types match at compile time via generics
  3. 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:

  1. Never use handle after destroy
  2. Provide valid buffer pointers
  3. Match buffer sizes to declared counts
  4. Not call from multiple threads simultaneously

Defense in Depth

Multiple layers of protection:

  1. Null checks - Explicit handle validation
  2. Bounds checks - Array access validation
  3. Type system - Compile-time generic checking
  4. 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

MetricTarget
Latency< 1 buffer
CPU usage< 50% of budget
MemoryPredictable, fixed
AllocationZero 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

  1. Memory allocation in audio thread
  2. Cache misses from scattered data
  3. Branch misprediction in complex logic
  4. Function call overhead for tiny operations
  5. Denormal processing in filter feedback

Zero-Allocation Processing

How bbx_audio achieves zero allocations during audio processing.

Strategy

All memory allocated upfront:

  1. Blocks added → buffers allocated
  2. Graph prepared → connection lookups built
  3. 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

LevelSizeLatency
L132-64 KB~4 cycles
L2256-512 KB~12 cycles
L34-32 MB~40 cycles
RAMGBs~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_simd feature)
  • 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:

FunctionDescription
fill_f32/f64Fill a buffer with a constant value
apply_gain_f32/f64Multiply samples by a gain factor
multiply_add_f32/f64Element-wise multiplication of two buffers
sin_f32/f64Vectorized sine computation

Additionally, the denormal module provides SIMD-accelerated batch denormal flushing:

  • flush_denormals_f32_batch
  • flush_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 TypeSIMD Type
f32f32x4
f64f64x4

SIMD Methods

MethodDescription
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:

BlockOptimization
OscillatorBlockVectorized waveform generation (4 samples at a time)
LfoBlockVectorized modulation signal generation
GainBlockVectorized gain application
PannerBlockVectorized 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

AspectScalarSIMD
ComplexitySimpleMore complex
ToolchainStable RustNightly required
DebuggingEasyHarder
PerformanceBaselineUp to 4x faster

Implementation Notes

  • Lane width is 4 for both f32 and f64 (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

  1. Create block in appropriate blocks/ subdirectory
  2. Add to BlockType enum
  3. Add builder method to GraphBuilder
  4. Write tests
  5. Update documentation

Modifying FFI

  1. Update Rust code in bbx_plugin
  2. Regenerate header with cbindgen
  3. Update bbx_graph.h if needed
  4. 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() or from_*() / 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

  1. prepare(): Call parameter.prepare(sample_rate) to initialize smoothing
  2. Update target: Use update_target() or set_target() at buffer start
  3. Check smoothing: Use is_smoothing() for fast-path optimization
  4. 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

  1. Add to blocks reference in docs
  2. Update README if significant
  3. 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

  1. Edge cases - Zero input, maximum values
  2. Parameter ranges - Valid and invalid values
  3. Sample types - Both f32 and f64
  4. Processing correctness - Expected output values
  5. 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:

BlockWhat's measuredVariations
OscillatorBlockWaveform generationsine, sawtooth, square, triangle
PannerBlockPan law + gain application-
GainBlockSIMD gain application-
LfoBlockModulation signal generationsine

Each block is benchmarked with:

  • Sample types: f32, f64
  • Buffer sizes: 256, 512, 1024

simd_graphs

Integration benchmarks for realistic DSP configurations:

GraphBlocksPurpose
simple_chainOscillatorSingle-block baseline
effect_chainOscillator → OverdriveSignal chain overhead
modulated_synthOscillator + LFOModulation path
multi_osc4 OscillatorsMultiple 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 samples
  • panner_f64/1024 - f64 panner, 1024 samples
  • graph_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 (--release is implicit)

Release Process

How to release new versions of bbx_audio.

Prerequisites

  • Push access to the repository
  • CARGO_REGISTRY_TOKEN configured in GitHub secrets

Version Bump

  1. Update version in all Cargo.toml files
  2. Update CHANGELOG.md
  3. 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

  1. Create and push a version tag:
git tag v0.2.0
git push origin v0.2.0
  1. GitHub Actions will:
    • Run tests
    • Publish to crates.io
    • Create GitHub release

Crate Publishing Order

Crates must be published in dependency order:

  1. bbx_core (no dependencies)
  2. bbx_midi (no internal dependencies)
  3. bbx_dsp (depends on bbx_core)
  4. bbx_file (depends on bbx_dsp)
  5. 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:

  1. Fix the issue
  2. Bump patch version
  3. 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-daz feature 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

  1. Read the changelog - Understand what changed
  2. Update dependencies - Bump version in Cargo.toml
  3. Run tests - Identify breaking changes
  4. Fix compilation errors - Update API calls
  5. 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:

  1. Check the changelog
  2. Search GitHub issues
  3. 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

  1. Check audio device is available
  2. Verify sample rate matches system
  3. Ensure output block is connected

Crackling/Glitches

  1. Increase buffer size
  2. Check CPU usage
  3. Avoid allocations in audio thread
  4. Profile for bottlenecks

Silence

  1. Verify block connections
  2. Check gain levels (not -inf dB)
  3. Ensure prepare() was called

FFI Issues

"Cannot find -ldsp"

  1. Ensure Rust crate builds successfully
  2. Check Corrosion configuration
  3. Verify staticlib crate 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

  1. Verify parameter indices match
  2. Check apply_parameters() implementation
  3. Ensure JUCE is calling Process() with params

Wrong Parameter Values

  1. Verify JSON/code generation sync
  2. Check parameter count matches
  3. Debug print received values

Getting Help

  1. Check GitHub Issues
  2. Search existing discussions
  3. 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).