Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Create Custom Modules

Extend Quiver with your own DSP modules using the Module Development Kit (MDK).

The GraphModule Trait

Every module in Layer 3 implements GraphModule:

pub trait GraphModule: Send {
    fn port_spec(&self) -> PortSpec;
    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues);
    fn reset(&mut self);
    fn set_sample_rate(&mut self, sample_rate: f64);
}

Step 1: Define Your Ports

use quiver::prelude::*;

pub struct MyDistortion {
    sample_rate: f64,
    drive: f64,
}

impl MyDistortion {
    pub fn new(sample_rate: f64) -> Self {
        Self {
            sample_rate,
            drive: 1.0,
        }
    }
}

Step 2: Implement GraphModule

impl GraphModule for MyDistortion {
    fn port_spec(&self) -> PortSpec {
        PortSpec::new()
            .with_input("in", PortDef::audio())
            .with_input("drive", PortDef::cv_unipolar().with_default(5.0))
            .with_output("out", PortDef::audio())
    }

    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
        let input = inputs.get("in");
        let drive = inputs.get("drive") / 5.0;  // Normalize CV

        // Soft clipping distortion
        let driven = input * (1.0 + drive * 4.0);
        let output = driven.tanh() * 5.0;  // Back to ±5V range

        outputs.set("out", output);
    }

    fn reset(&mut self) {
        self.drive = 1.0;
    }

    fn set_sample_rate(&mut self, sample_rate: f64) {
        self.sample_rate = sample_rate;
    }
}

Step 3: Use Your Module

let mut patch = Patch::new(44100.0);

let vco = patch.add("vco", Vco::new(44100.0));
let dist = patch.add("dist", MyDistortion::new(44100.0));
let output = patch.add("output", StereoOutput::new());

patch.connect(vco.out("saw"), dist.in_("in"))?;
patch.connect(dist.out("out"), output.in_("left"))?;

Using Module Templates

The MDK provides templates for common module types:

use quiver::mdk::*;

let template = ModuleTemplate::new("BitCrusher", ModuleCategory::Effect)
    .with_input(PortTemplate::audio("in"))
    .with_input(PortTemplate::cv_unipolar("bits").with_default(8.0))
    .with_input(PortTemplate::cv_unipolar("rate").with_default(10.0))
    .with_output(PortTemplate::audio("out"));

// Generate skeleton code
let code = template.generate_rust_code();
println!("{}", code);

Testing Custom Modules

Use the testing harness:

let mut harness = ModuleTestHarness::new(MyDistortion::new(44100.0));

// Test reset behavior
let result = harness.test_reset();
assert!(result.passed, "Reset test: {}", result.message);

// Test sample rate handling
let result = harness.test_sample_rate_change(48000.0);
assert!(result.passed, "Sample rate test: {}", result.message);

// Test output bounds
let result = harness.test_output_bounds(-10.0..=10.0);
assert!(result.passed, "Bounds test: {}", result.message);

Signal Analysis

Analyze your module’s output:

let analysis = AudioAnalysis::new(44100.0);

// Collect samples
let samples: Vec<f64> = (0..44100)
    .map(|_| module.tick(&inputs, &mut outputs))
    .collect();

println!("RMS Level: {:.2} dB", analysis.rms_db(&samples));
println!("Peak: {:.2}V", analysis.peak(&samples));
println!("DC Offset: {:.4}V", analysis.dc_offset(&samples));
println!("Estimated Frequency: {:.1} Hz", analysis.frequency_estimate(&samples));

Documentation Generation

Auto-generate docs for your module:

let doc_gen = DocGenerator::new(&my_module);

// Markdown format
let markdown = doc_gen.generate(DocFormat::Markdown);
println!("{}", markdown);

// HTML format
let html = doc_gen.generate(DocFormat::Html);

Example: Complete Custom Module

//! How-To: Create Custom Modules
//!
//! Demonstrates building a custom DSP module using the GraphModule trait.
//! This example creates a bit crusher effect.
//!
//! Run with: cargo run --example howto_custom_module

use quiver::prelude::*;

/// A bit crusher effect that reduces sample resolution and rate.
///
/// # Ports
///
/// ## Inputs
/// - 0 (`in`): Audio input (±5V)
/// - 1 (`bits`): Bit depth reduction (1-16 bits via 0-10V CV)
/// - 2 (`rate`): Sample rate reduction factor (1-64x via 0-10V CV)
///
/// ## Outputs
/// - 10 (`out`): Crushed audio output (±5V)
pub struct BitCrusher {
    sample_rate: f64,
    hold_sample: f64,
    hold_counter: f64,
    spec: PortSpec,
}

impl BitCrusher {
    pub fn new(sample_rate: f64) -> Self {
        Self {
            sample_rate,
            hold_sample: 0.0,
            hold_counter: 0.0,
            spec: PortSpec {
                inputs: vec![
                    // Audio input
                    PortDef::new(0, "in", SignalKind::Audio),
                    // Bit depth: 0V = 16 bits (clean), 10V = 1 bit (extreme)
                    PortDef::new(1, "bits", SignalKind::CvUnipolar).with_default(0.0),
                    // Rate reduction: 0V = 1x (clean), 10V = 64x reduction
                    PortDef::new(2, "rate", SignalKind::CvUnipolar).with_default(0.0),
                ],
                outputs: vec![
                    // Audio output
                    PortDef::new(10, "out", SignalKind::Audio),
                ],
            },
        }
    }
}

impl GraphModule for BitCrusher {
    fn port_spec(&self) -> &PortSpec {
        &self.spec
    }

    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
        let input = inputs.get_or(0, 0.0);
        let bits_cv = inputs.get_or(1, 0.0).clamp(0.0, 10.0);
        let rate_cv = inputs.get_or(2, 0.0).clamp(0.0, 10.0);

        // Convert CV to parameters
        // bits_cv: 0V = 16 bits, 10V = 1 bit
        let bits = 16.0 - (bits_cv / 10.0 * 15.0);
        let levels = 2.0_f64.powf(bits);

        // rate_cv: 0V = 1x, 10V = 64x reduction
        let rate_reduction = 1.0 + (rate_cv / 10.0 * 63.0);

        // Sample rate reduction (sample & hold)
        self.hold_counter += 1.0;
        if self.hold_counter >= rate_reduction {
            self.hold_counter = 0.0;
            self.hold_sample = input;
        }

        // Bit depth reduction (quantization)
        // Normalize to 0-1, quantize, scale back
        let normalized = (self.hold_sample + 5.0) / 10.0; // 0 to 1
        let quantized = (normalized * levels).round() / levels;
        let output = quantized * 10.0 - 5.0; // Back to ±5V

        outputs.set(10, output);
    }

    fn reset(&mut self) {
        self.hold_sample = 0.0;
        self.hold_counter = 0.0;
    }

    fn set_sample_rate(&mut self, sample_rate: f64) {
        self.sample_rate = sample_rate;
    }
}

fn main() {
    let sample_rate = 44100.0;

    println!("=== Custom Module Demo: BitCrusher ===\n");

    // Create a patch with our custom module
    let mut patch = Patch::new(sample_rate);

    let vco = patch.add("vco", Vco::new(sample_rate));
    let crusher = patch.add("crusher", BitCrusher::new(sample_rate));
    let output = patch.add("output", StereoOutput::new());

    // CV control for the effect
    let bits_cv = patch.add("bits_cv", Offset::new(0.0)); // Start clean
    let rate_cv = patch.add("rate_cv", Offset::new(0.0));

    // Connections
    patch.connect(vco.out("sin"), crusher.in_("in")).unwrap();
    patch
        .connect(bits_cv.out("out"), crusher.in_("bits"))
        .unwrap();
    patch
        .connect(rate_cv.out("out"), crusher.in_("rate"))
        .unwrap();
    patch
        .connect(crusher.out("out"), output.in_("left"))
        .unwrap();

    patch.set_output(output.id());
    patch.compile().unwrap();

    // Test at different settings
    println!("Testing BitCrusher at various settings:\n");

    // We'll simulate different CV values by creating new patches
    for (bits_v, rate_v, desc) in [
        (0.0, 0.0, "Clean (16-bit, no rate reduction)"),
        (5.0, 0.0, "8-bit, full rate"),
        (8.0, 0.0, "4-bit, full rate"),
        (0.0, 5.0, "16-bit, 32x rate reduction"),
        (7.0, 5.0, "Lo-fi (5-bit, 32x reduction)"),
        (9.0, 8.0, "Extreme (2-bit, 50x reduction)"),
    ] {
        let mut test_patch = Patch::new(sample_rate);

        let vco = test_patch.add("vco", Vco::new(sample_rate));
        let crusher = test_patch.add("crusher", BitCrusher::new(sample_rate));
        let bits = test_patch.add("bits", Offset::new(bits_v));
        let rate = test_patch.add("rate", Offset::new(rate_v));
        let output = test_patch.add("output", StereoOutput::new());

        test_patch
            .connect(vco.out("sin"), crusher.in_("in"))
            .unwrap();
        test_patch
            .connect(bits.out("out"), crusher.in_("bits"))
            .unwrap();
        test_patch
            .connect(rate.out("out"), crusher.in_("rate"))
            .unwrap();
        test_patch
            .connect(crusher.out("out"), output.in_("left"))
            .unwrap();

        test_patch.set_output(output.id());
        test_patch.compile().unwrap();

        // Generate samples and analyze
        let num_samples = (sample_rate * 0.1) as usize;
        let mut samples = Vec::with_capacity(num_samples);

        for _ in 0..num_samples {
            let (left, _) = test_patch.tick();
            samples.push(left);
        }

        let peak = samples.iter().map(|s| s.abs()).fold(0.0_f64, f64::max);
        let rms = (samples.iter().map(|s| s * s).sum::<f64>() / num_samples as f64).sqrt();

        // Count unique values (rough measure of bit reduction)
        let mut unique: Vec<i32> = samples.iter().map(|s| (s * 1000.0) as i32).collect();
        unique.sort();
        unique.dedup();

        println!("{}", desc);
        println!("  Bits CV: {:.1}V, Rate CV: {:.1}V", bits_v, rate_v);
        println!(
            "  Peak: {:.2}V, RMS: {:.2}V, Unique levels: {}\n",
            peak,
            rms,
            unique.len()
        );
    }

    // Show the port specification
    let module = BitCrusher::new(sample_rate);
    let spec = module.port_spec();

    println!("--- Port Specification ---");
    println!("Inputs:");
    for def in &spec.inputs {
        println!(
            "  {} (id={}): {:?}, default={:.1}V",
            def.name, def.id, def.kind, def.default
        );
    }
    println!("Outputs:");
    for def in &spec.outputs {
        println!("  {} (id={}): {:?}", def.name, def.id, def.kind);
    }
}

Registering for Serialization

Add your module to the registry:

let mut registry = ModuleRegistry::new();

registry.register("my_distortion", |sr| {
    Box::new(MyDistortion::new(sr))
});

// Now patches with "my_distortion" can be loaded
let patch = Patch::from_def(&def, &registry, 44100.0)?;

Best Practices

  1. Validate inputs: Clamp CV values to expected ranges
  2. Handle edge cases: Zero crossings, near-zero values
  3. Avoid allocations: No heap allocations in tick()
  4. Document signal ranges: Specify expected voltage ranges
  5. Test thoroughly: Use the test harness before shipping