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

Quiver: Modular Audio Synthesis

“A quiver is a directed graph — nodes connected by arrows. In audio, our nodes are modules, our arrows are patch cables, and signal flows through their composition.”

Quiver is a Rust library for building modular audio synthesis systems. It combines the mathematical elegance of category theory with the tactile joy of patching a hardware modular synthesizer.

flowchart LR
    subgraph "Oscillator"
        VCO[🎵 VCO]
    end
    subgraph "Filter"
        VCF[📊 VCF]
    end
    subgraph "Amplifier"
        VCA[🔊 VCA]
    end
    subgraph "Envelope"
        ADSR[📈 ADSR]
    end

    VCO -->|saw| VCF
    VCF -->|lowpass| VCA
    ADSR -->|env| VCF
    ADSR -->|env| VCA
    VCA --> Output[🔈]

Why Quiver?

Type-Safe Patching

Quiver catches connection errors at compile time. Connect a gate to a V/Oct input? The type system prevents it before you hear a single pop.

Hardware-Inspired Semantics

Voltages follow real modular conventions:

  • ±5V for audio signals
  • 1V/octave for pitch (0V = C4)
  • 0-5V for gates and triggers
  • 0-10V for unipolar CV

Mathematical Foundations

Built on Arrow-style functional combinators, Quiver lets you compose DSP operations like mathematical functions:

$$f \ggg g = g \circ f$$

Chain two modules and their types compose automatically.

Three-Layer Architecture

graph TB
    subgraph "Layer 3: Patch Graph"
        G[Runtime Topology]
    end
    subgraph "Layer 2: Port System"
        P[Signal Conventions]
    end
    subgraph "Layer 1: Typed Combinators"
        C[Arrow Composition]
    end

    C --> P --> G

    style C fill:#4a9eff,color:#fff
    style P fill:#f9a826,color:#fff
    style G fill:#50c878,color:#fff
  1. Layer 1 — Compile-time type checking with zero-cost abstractions
  2. Layer 2 — Hardware-inspired signal conventions
  3. Layer 3 — Runtime-configurable patching like a real modular

Quick Taste

//! Quick Taste Example
//!
//! A minimal example showing the core Quiver workflow.
//!
//! Run with: cargo run --example quick_taste

use quiver::prelude::*;

fn main() {
    // Create a patch at CD-quality sample rate
    let mut patch = Patch::new(44100.0);

    // Add an oscillator and output
    let vco = patch.add("vco", Vco::new(44100.0));
    let output = patch.add("out", StereoOutput::new());

    // Connect the sawtooth wave to both channels
    patch.connect(vco.out("saw"), output.in_("left")).unwrap();
    patch.connect(vco.out("saw"), output.in_("right")).unwrap();

    // Compile the patch for processing
    patch.set_output(output.id());
    patch.compile().unwrap();

    // Generate one second of audio
    let mut samples = Vec::new();
    for _ in 0..44100 {
        let (left, _right) = patch.tick();
        samples.push(left);
    }

    // Report the results
    let peak = samples.iter().map(|s| s.abs()).fold(0.0_f64, f64::max);
    println!("Generated {} samples", samples.len());
    println!("Peak amplitude: {:.2}V", peak);
}

What You’ll Learn

This documentation guides you from first patch to advanced synthesis:

The Name

In category theory, a quiver is a directed graph: objects connected by morphisms. In our world:

Category TheoryQuiver Audio
ObjectsModules
Morphisms (Arrows)Patch Cables
CompositionSignal Flow
IdentityPass-through

The math isn’t just decoration—it guides the API design and ensures compositions are well-typed.


Ready to patch? Start with Installation.

Installation

Getting Quiver into your project is straightforward. The library is pure Rust with minimal dependencies.

Prerequisites

  • Rust 1.70+ (2021 edition)
  • Cargo (comes with Rust)

Verify your installation:

rustc --version
cargo --version

Adding Quiver to Your Project

As a Dependency

Add to your Cargo.toml:

[dependencies]
quiver = { git = "https://github.com/alexnodeland/quiver" }

Or with specific features:

[dependencies]
quiver = { git = "https://github.com/alexnodeland/quiver", features = ["simd"] }

Available Features

FeatureDefaultDescription
stdYesFull functionality including OSC, visualization (implies alloc)
allocNoSerialization, presets, and I/O for no_std + heap environments
simdNoSIMD vectorization for block processing (works with any tier)

Feature Tiers

Quiver supports three tiers for different environments:

Tier 1: Core Only (default-features = false)

For bare-metal embedded systems without heap allocation:

[dependencies]
quiver = { git = "https://github.com/alexnodeland/quiver", default-features = false }

Includes all core DSP modules: oscillators, filters, envelopes, amplifiers, mixers, utilities, logic modules, analog modeling, polyphony, and the patch graph.

Tier 2: With Alloc (features = ["alloc"])

For WASM web apps and embedded systems with heap:

[dependencies]
quiver = { git = "https://github.com/alexnodeland/quiver", default-features = false, features = ["alloc"] }

Adds:

  • Serialization - JSON save/load for patches (PatchDef, ModuleDef, CableDef)
  • Presets - Ready-to-use patch presets (ClassicPresets, PresetLibrary)
  • I/O Modules - External inputs/outputs, MIDI state (AtomicF64, MidiState)

Tier 3: Full Std (default)

For desktop applications:

[dependencies]
quiver = { git = "https://github.com/alexnodeland/quiver" }

Adds:

  • Extended I/O - OSC protocol, Web Audio interfaces
  • Visual Tools - Scope, Spectrum Analyzer, Level Meter, Automation Recorder
  • MDK - Module Development Kit for creating custom modules

Feature Matrix

TierDSPSerializePresetsI/OOSCVisualMDK
Core
alloc
std

Implementation Notes

  • Uses BTreeMap instead of HashMap in non-std modes (no hashing required)
  • Includes a seedable Xorshift128+ RNG for deterministic random generation
  • Math functions provided by libm (sin, cos, pow, sqrt, exp, log, etc.)
  • Heap allocations via alloc crate (Vec, Box, String)

Verifying Installation

Create a simple test program:

use quiver::prelude::*;

fn main() {
    let patch = Patch::new(44100.0);
    println!("Quiver is working! Patch created at {}Hz", 44100.0);
}

Run it:

cargo run

Building the Examples

Clone the repository and run an example:

git clone https://github.com/alexnodeland/quiver
cd quiver
cargo run --example simple_patch

Building Documentation

Generate the API documentation locally:

cargo doc --open

This opens the rustdoc documentation in your browser with all type information and examples.

Editor Setup

For the best experience, use an editor with Rust support:

  • VS Code with rust-analyzer extension
  • IntelliJ IDEA with Rust plugin
  • Neovim with rust-tools.nvim

Type hints are particularly helpful given Quiver’s strong typing—your editor will show you exactly what signals flow where.


Next: Your First Patch

Your First Patch

Let’s build a complete synthesizer voice: an oscillator through a filter, shaped by an envelope. This is the classic subtractive synthesis signal path.

flowchart LR
    VCO[VCO] -->|saw| VCF[VCF]
    VCF -->|lowpass| VCA[VCA]
    ADSR[ADSR] -->|env| VCF
    ADSR -->|env| VCA
    GATE[Gate] -->|trigger| ADSR
    VCA --> OUT[Output]

The Complete Example

//! First Patch Example
//!
//! A complete subtractive synthesizer voice demonstrating the core
//! Quiver workflow: VCO → VCF → VCA with ADSR envelope shaping.
//!
//! Run with: cargo run --example first_patch

use quiver::prelude::*;
use std::sync::Arc;

fn main() {
    // CD-quality sample rate
    let sample_rate = 44100.0;

    // Create our patch (virtual modular case)
    let mut patch = Patch::new(sample_rate);

    // External control: gate signal for envelope triggering
    let gate_cv = Arc::new(AtomicF64::new(0.0));

    // Add modules to the patch
    let gate = patch.add("gate", ExternalInput::gate(Arc::clone(&gate_cv)));
    let vco = patch.add("vco", Vco::new(sample_rate));
    let vcf = patch.add("vcf", Svf::new(sample_rate));
    let vca = patch.add("vca", Vca::new());
    let env = patch.add("env", Adsr::new(sample_rate));
    let output = patch.add("output", StereoOutput::new());

    // Patch cables: signal flow
    // Gate triggers the envelope
    patch.connect(gate.out("out"), env.in_("gate")).unwrap();

    // VCO → VCF → VCA → Output (main audio path)
    patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
    patch.connect(vcf.out("lp"), vca.in_("in")).unwrap();
    patch.connect(vca.out("out"), output.in_("left")).unwrap();
    patch.connect(vca.out("out"), output.in_("right")).unwrap();

    // Envelope modulates both filter and amplitude
    patch.connect(env.out("env"), vcf.in_("cutoff")).unwrap();
    patch.connect(env.out("env"), vca.in_("cv")).unwrap();

    // Compile the patch for processing
    patch.set_output(output.id());
    patch.compile().unwrap();

    println!(
        "Patch compiled: {} modules, {} cables",
        patch.node_count(),
        patch.cable_count()
    );
    println!();

    // Play a note: gate on
    println!("Note ON - Gate rises to +5V");
    gate_cv.set(5.0);

    // Process attack phase (0.5 seconds)
    let attack_samples = (sample_rate * 0.5) as usize;
    let mut peak = 0.0_f64;

    for _ in 0..attack_samples {
        let (left, _) = patch.tick();
        peak = peak.max(left.abs());
    }
    println!("  Attack complete, peak level: {:.2}V", peak);

    // Release the note: gate off
    println!("Note OFF - Gate falls to 0V");
    gate_cv.set(0.0);

    // Process release phase
    let release_samples = (sample_rate * 1.0) as usize;
    let mut release_peak = 0.0_f64;

    for _ in 0..release_samples {
        let (left, _) = patch.tick();
        release_peak = release_peak.max(left.abs());
    }
    println!("  Release complete, final level: {:.4}V", release_peak);

    println!();
    println!("Subtractive synthesis voice complete!");
}

Understanding the Code

Creating a Patch

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

The Patch is your virtual modular case. The sample rate (44100 Hz = CD quality) determines timing precision for all modules.

Adding Modules

let vco = patch.add("vco", Vco::new(44100.0));
let vcf = patch.add("vcf", Svf::new(44100.0));

Each module gets a unique name and returns a NodeHandle. This handle lets you reference the module’s ports.

Making Connections

patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();

The syntax mirrors real patching:

  • vco.out("saw") — the sawtooth output jack
  • vcf.in_("in") — the filter’s audio input jack

Note: We use in_() instead of in() because in is a Rust keyword.

Compiling the Patch

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

Compilation:

  1. Performs topological sort (determines processing order)
  2. Validates all connections
  3. Detects any cycles (feedback loops)

Processing Audio

let (left, right) = patch.tick();

Each tick() advances the patch by one sample, returning stereo output.

Signal Flow in Detail

StageModuleFunction
1VCOGenerates raw waveform (saw wave)
2VCFFilters harmonics (lowpass)
3VCAControls amplitude
4ADSRShapes volume over time
5OutputRoutes to stereo outputs

The envelope simultaneously controls:

  • Filter cutoff — brighter attack, darker sustain
  • VCA level — shapes volume contour

This dual modulation creates the characteristic “filter sweep” sound of analog synths.

What’s Happening Mathematically

The signal chain computes:

$$\text{output}(t) = \text{env}(t) \cdot \text{LPF}(\text{saw}(t), \text{env}(t) \cdot f_c)$$

Where:

  • $\text{saw}(t)$ is the sawtooth oscillator at time $t$
  • $\text{LPF}$ is the lowpass filter
  • $\text{env}(t)$ is the envelope value
  • $f_c$ is the base cutoff frequency

The envelope modulating both the filter and amplitude creates the classic synth timbre.

Experimenting

Try these modifications:

  1. Different waveform: Change vco.out("saw") to vco.out("sqr") for a hollow, clarinet-like tone

  2. Add an LFO: Modulate the filter for a rhythmic wobble

  3. Change envelope times: Longer attack for pads, shorter for percussion


Next: Understanding Signal Flow

Understanding Signal Flow

In Quiver, signals flow through modules following the conventions of hardware modular synthesizers. Understanding these conventions is key to creating patches that behave predictably.

Voltage Ranges

Quiver models its signals on the Eurorack standard:

graph LR
    subgraph "Audio Signals"
        A[±5V Peak<br/>AC-coupled]
    end
    subgraph "Control Voltage"
        B[0-10V Unipolar]
        C[±5V Bipolar]
    end
    subgraph "Pitch"
        D[1V/Octave<br/>0V = C4]
    end
    subgraph "Triggers/Gates"
        E[0V Low<br/>+5V High]
    end

    style A fill:#4a9eff,color:#fff
    style B fill:#f9a826,color:#000
    style C fill:#f9a826,color:#000
    style D fill:#e74c3c,color:#fff
    style E fill:#50c878,color:#fff

Audio Signals

Audio oscillates between -5V and +5V:

$$\text{audio}(t) \in [-5, +5]$$

This matches Eurorack levels and allows headroom for mixing.

Control Voltage (CV)

Two types of control voltage:

TypeRangeUse Case
Unipolar0V to +10VFilter cutoff, LFO rate, envelope times
Bipolar-5V to +5VVibrato, pan position, FM

Volt-per-Octave (V/Oct)

Pitch follows the 1 Volt per Octave standard:

$$f = f_0 \cdot 2^{V}$$

Where $f_0 = 261.63$ Hz (C4) at 0V.

VoltageNoteFrequency
-1VC3130.81 Hz
0VC4261.63 Hz
+1VC5523.25 Hz
+2VC61046.50 Hz

Gates and Triggers

sequenceDiagram
    participant G as Gate
    participant T as Trigger

    Note over G: Gate (sustained)
    G->>G: 0V (off)
    G->>G: +5V (on, held)
    G->>G: +5V (still on)
    G->>G: 0V (off)

    Note over T: Trigger (impulse)
    T->>T: 0V
    T->>T: +5V (1-10ms pulse)
    T->>T: 0V
  • Gate: Sustained high signal (key held down)
  • Trigger: Brief pulse (≈1-10ms) to start events

Signal Types in Code

Quiver tracks signal types through SignalKind:

pub enum SignalKind {
    Audio,           // ±5V AC-coupled
    CvBipolar,       // ±5V control
    CvUnipolar,      // 0-10V control
    VoltPerOctave,   // 1V/Oct pitch
    Gate,            // 0V or +5V sustained
    Trigger,         // 0V or +5V brief pulse
    Clock,           // Regular timing pulses
}

The type system helps catch mismatches:

// This will warn: connecting audio to a V/Oct input
patch.connect(vco.out("saw"), another_vco.in_("voct"))

Module Input Behavior

Input Summing

Multiple cables to one input are summed:

flowchart LR
    LFO1[LFO 1] -->|+2V| SUM((Σ))
    LFO2[LFO 2] -->|+3V| SUM
    SUM -->|+5V| VCF[VCF cutoff]

This models analog behavior where multiple CVs combine.

Attenuverters

Many inputs support attenuation and inversion:

// Half strength, inverted
patch.connect_with(
    lfo.out("sin"),
    vcf.in_("cutoff"),
    Cable::new().with_attenuation(-0.5),
)?;

The attenuverter range is typically -2 to +2, allowing inversion and some gain.

Normalled Connections

Some inputs have default sources when unpatched:

flowchart LR
    LEFT[Left Input] --> NORM{Unpatched?}
    NORM -->|Yes| RIGHT[Uses Left<br/>signal]
    NORM -->|No| EXT[External<br/>source]

The StereoOutput module, for example, normalizes right to left if right is unpatched.

Processing Order

Quiver automatically determines processing order through topological sort:

flowchart TD
    VCO[1. VCO] --> VCF[2. VCF]
    LFO[1. LFO] --> VCF
    VCF --> VCA[3. VCA]
    ENV[1. ENV] --> VCA
    VCA --> OUT[4. Output]

Modules with no dependencies process first. The algorithm (Kahn’s) ensures every module has its inputs ready before processing.

Common Patching Patterns

Modulation

flowchart LR
    LFO[LFO] -->|mod| TARGET[Target Parameter]
    OFFSET[Offset] -->|base| TARGET

Combine a static offset with an LFO for “center + modulation” control.

Envelope Following

flowchart LR
    AUDIO[Audio In] --> VCA[VCA]
    AUDIO --> ENV[Envelope<br/>Follower]
    ENV -->|level| VCA

Use audio amplitude to control other parameters.

FM (Frequency Modulation)

flowchart LR
    MOD[Modulator<br/>VCO] -->|fm| CARRIER[Carrier<br/>VCO]
    CARRIER --> OUT[Output]

Audio-rate modulation of oscillator frequency creates complex timbres.


Next: The Quiver Philosophy

The Quiver Philosophy

Quiver isn’t just another DSP library. It’s built on a philosophy that bridges abstract mathematics with hands-on synthesis.

The Name

A quiver in category theory is a directed graph—a collection of objects connected by arrows. This is exactly what a modular synthesizer is:

graph LR
    subgraph "Category Theory"
        O1((Object)) -->|morphism| O2((Object))
        O2 -->|morphism| O3((Object))
    end
graph LR
    subgraph "Modular Synthesis"
        M1[Module] -->|cable| M2[Module]
        M2 -->|cable| M3[Module]
    end

The parallel is precise:

  • Objects → Modules (signal processors)
  • Morphisms/Arrows → Patch cables (signal flow)
  • Composition → Signal chaining
  • Identity → Pass-through modules

This isn’t mere analogy—it guides the entire API design.

Three Layers, One System

Quiver’s architecture reflects different levels of abstraction:

graph TB
    subgraph "Layer 3: Patch Graph"
        L3["Runtime flexibility<br/>Dynamic topology<br/>Hardware-like patching"]
    end
    subgraph "Layer 2: Port System"
        L2["Signal conventions<br/>Type-erased interface<br/>Hardware semantics"]
    end
    subgraph "Layer 1: Typed Combinators"
        L1["Compile-time safety<br/>Arrow composition<br/>Zero-cost abstractions"]
    end

    L1 --> L2 --> L3

    style L1 fill:#4a9eff,color:#fff
    style L2 fill:#f9a826,color:#000
    style L3 fill:#50c878,color:#fff

Layer 1: Mathematical Purity

At the foundation, modules are Arrow combinators:

// Sequential composition: f >>> g
let chain = osc.chain(filter);

// Parallel composition: f *** g
let stereo = left.parallel(right);

// Fanout: f &&& g
let split = signal.fanout(fx1, fx2);

These operations are type-checked at compile time. If types don’t match, the program doesn’t compile.

Arrow Laws hold: $$\text{id} \ggg f = f = f \ggg \text{id}$$ $$(f \ggg g) \ggg h = f \ggg (g \ggg h)$$

Layer 2: Hardware Semantics

The port system brings real-world meaning:

  • ±5V audio because that’s what mixers expect
  • 1V/octave because that’s the pitch standard
  • Gates and triggers because that’s how sequencers work

This layer ensures your patches behave like hardware.

Layer 3: Patching Freedom

The graph layer gives runtime flexibility:

// Add modules dynamically
let new_lfo = patch.add("wobble", Lfo::new(44100.0));

// Connect at runtime
patch.connect(new_lfo.out("sin"), vcf.in_("cutoff"))?;

// Recompile and continue
patch.compile()?;

This is how real modular synthesizers work—you can repatch while playing.

Design Principles

1. Type Safety Where It Matters

Quiver catches errors at compile time when possible:

// This won't compile: f64 can't go where (f64, f64) is expected
let bad_chain = mono_module.chain(stereo_module);

But it allows runtime flexibility when needed:

// This works: runtime type checking with clear error messages
patch.connect(vco.out("saw"), vcf.in_("cutoff"))
    .expect("Signal type mismatch");

2. Zero-Cost Abstractions

Layer 1 combinators compile to the same code as hand-written loops:

// This combinator chain...
let synth = vco.chain(vcf).chain(vca);

// ...compiles to equivalent of:
fn tick(&mut self) -> f64 {
    self.vca.tick(self.vcf.tick(self.vco.tick(())))
}

The abstraction is free—no runtime overhead.

3. Hardware-Inspired Defaults

Modules behave like their analog counterparts:

  • VCO starts at C4 (0V = 261.63 Hz)
  • ADSR has sensible attack/decay/sustain/release
  • Filter resonance ranges from clean to self-oscillation

You can start patching immediately without configuring everything.

4. Progressive Complexity

Simple things are simple:

let output = vco.chain(output);  // One line, done

Complex things are possible:

// Polyphonic patch with unison, analog modeling, SIMD processing
let poly = PolyPatch::new(voices, voice_patch)
    .with_unison(UnisonConfig::new(3).with_detune(0.1))
    .with_analog_variation(ComponentModel::default());

The Hybrid Approach

Most DSP libraries force a choice:

  • Static: Great types, but can’t repatch at runtime
  • Dynamic: Flexible, but crashes at runtime on type errors

Quiver offers both:

flowchart TB
    subgraph "Programmatic (Layer 1)"
        P1[Compile-time types]
        P2[Arrow composition]
        P3[Zero-cost]
    end

    subgraph "Runtime (Layer 3)"
        R1[Dynamic patching]
        R2[Signal validation]
        R3[Topological sort]
    end

    P1 --> BRIDGE[GraphModule trait]
    R1 --> BRIDGE

    style BRIDGE fill:#f9a826,color:#000

Write your core DSP with full type safety, then expose it to the graph system for flexible routing.

Mathematical Foundations

The math isn’t decoration—it ensures correctness:

PropertyMusical Meaning
AssociativityGrouping doesn’t affect sound
IdentityPass-through doesn’t color signal
CompositionChaining is predictable
Functor lawsSignal mapping is consistent

When you chain modules, you’re performing mathematical composition. The laws guarantee the result is what you expect.

Why This Matters

  1. Fewer bugs: Type system catches connection errors
  2. Better performance: Zero-cost abstractions
  3. Clearer thinking: Math clarifies design
  4. Hardware familiarity: Patches work like real modulars

Quiver aims to be the missing link between academic DSP theory and hands-on synthesis. The math makes it reliable; the hardware semantics make it intuitive.


Ready to dive deeper? Continue to Tutorials.

Basic Subtractive Synthesis

Subtractive synthesis is the foundation of analog synthesizers. Start with a harmonically rich waveform, then sculpt it by filtering away frequencies.

flowchart LR
    OSC[Oscillator<br/>Rich harmonics] --> FILTER[Filter<br/>Remove harmonics]
    FILTER --> AMP[Amplifier<br/>Shape volume]
    AMP --> OUT[Output]

    style OSC fill:#4a9eff,color:#fff
    style FILTER fill:#f9a826,color:#000
    style AMP fill:#50c878,color:#fff

The Physics of Waveforms

Different waveforms have different harmonic content:

WaveformHarmonicsSound Character
SineFundamental onlyPure, flute-like
TriangleOdd harmonics (weak)Soft, clarinet-like
SawtoothAll harmonicsBright, brassy
SquareOdd harmonics (strong)Hollow, woody

The mathematical representation:

Sawtooth wave: $$x(t) = \frac{2}{\pi} \sum_{k=1}^{\infty} \frac{(-1)^{k+1}}{k} \sin(2\pi k f t)$$

This infinite sum of harmonics is what gives the sawtooth its brightness.

Building the Patch

//! Tutorial: Basic Subtractive Synthesis
//!
//! This example demonstrates the fundamentals of subtractive synthesis:
//! starting with a harmonically rich oscillator and shaping it with a filter.
//!
//! Run with: cargo run --example tutorial_subtractive

use quiver::prelude::*;

fn main() {
    let sample_rate = 44100.0;
    let mut patch = Patch::new(sample_rate);

    // The oscillator: source of harmonics
    let vco = patch.add("vco", Vco::new(sample_rate));

    // The filter: subtracts harmonics
    let vcf = patch.add("vcf", Svf::new(sample_rate));

    // Output stage
    let output = patch.add("output", StereoOutput::new());

    // Offset module to set filter cutoff (in CV range)
    // 5V corresponds to a medium-high cutoff frequency
    let cutoff = patch.add("cutoff", Offset::new(5.0));

    // Connect: Saw wave → Filter → Output
    patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
    patch.connect(cutoff.out("out"), vcf.in_("cutoff")).unwrap();
    patch.connect(vcf.out("lp"), output.in_("left")).unwrap();

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

    // Generate samples and analyze harmonic content
    println!("=== Subtractive Synthesis Demo ===\n");

    // Collect one period of audio (assuming ~261Hz C4)
    let period_samples = (sample_rate / 261.63) as usize;
    let mut samples: Vec<f64> = Vec::new();

    for _ in 0..period_samples * 10 {
        let (left, _) = patch.tick();
        samples.push(left);
    }

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

    println!("Sawtooth → Lowpass Filter");
    println!("  Peak amplitude: {:.2}V", peak);
    println!("  RMS level: {:.2}V", rms);
    println!("  Samples generated: {}", samples.len());

    // Compare with unfiltered saw
    let mut raw_patch = Patch::new(sample_rate);
    let raw_vco = raw_patch.add("vco", Vco::new(sample_rate));
    let raw_out = raw_patch.add("output", StereoOutput::new());
    raw_patch
        .connect(raw_vco.out("saw"), raw_out.in_("left"))
        .unwrap();
    raw_patch.set_output(raw_out.id());
    raw_patch.compile().unwrap();

    let mut raw_samples: Vec<f64> = Vec::new();
    for _ in 0..period_samples * 10 {
        let (left, _) = raw_patch.tick();
        raw_samples.push(left);
    }

    let raw_peak = raw_samples.iter().map(|s| s.abs()).fold(0.0_f64, f64::max);
    let raw_rms =
        (raw_samples.iter().map(|s| s * s).sum::<f64>() / raw_samples.len() as f64).sqrt();

    println!("\nRaw Sawtooth (unfiltered)");
    println!("  Peak amplitude: {:.2}V", raw_peak);
    println!("  RMS level: {:.2}V", raw_rms);

    println!("\nThe filter has smoothed the waveform by removing high harmonics.");
    println!("Notice the lower RMS - less high-frequency energy means a softer sound.");
}

Understanding the Filter

The state-variable filter (SVF) in Quiver simultaneously outputs:

  • Lowpass — removes high frequencies
  • Bandpass — isolates a frequency band
  • Highpass — removes low frequencies
  • Notch — removes a specific band
graph TB
    subgraph "SVF Outputs"
        IN[Audio In] --> SVF[State Variable<br/>Filter]
        SVF --> LP[Lowpass]
        SVF --> BP[Bandpass]
        SVF --> HP[Highpass]
        SVF --> NOTCH[Notch]
    end

Filter Response

The lowpass filter attenuates frequencies above the cutoff:

$$H(f) = \frac{1}{\sqrt{1 + (f/f_c)^{2n}}}$$

Where $f_c$ is cutoff frequency and $n$ is filter order.

Quiver’s SVF is 12dB/octave (2-pole), meaning frequencies one octave above cutoff are reduced by 12dB.

Resonance

Resonance (Q) boosts frequencies near cutoff:

graph LR
    subgraph "Resonance Effect"
        FLAT[Low Q<br/>Flat response]
        PEAK[High Q<br/>Resonant peak]
    end

At maximum resonance, the filter self-oscillates, becoming a sine wave generator.

Experimenting

  1. Try different waveforms: Change "saw" to "sqr" or "tri"
  2. Adjust cutoff: Lower values = darker, muffled sound
  3. Add resonance: Creates a vowel-like quality
  4. Mix waveforms: Combine saw and sqr for thickness

Classic Tones

Synth SoundWaveformFilterCharacter
Moog BassSawLP, low cutoffFat, warm
Oberheim PadSaw + Saw (detuned)LP, med cutoffLush, wide
TB-303 AcidSawLP, high resonanceSquelchy
CS-80 BrassSawLP, following envelopeBrassy attack

Next: Envelope Shaping

Envelope Shaping

An envelope generator shapes how a parameter changes over time. The classic ADSR (Attack, Decay, Sustain, Release) envelope is the heartbeat of synthesis.

graph LR
    subgraph "ADSR Envelope"
        A[Attack] --> D[Decay]
        D --> S[Sustain]
        S --> R[Release]
    end

Anatomy of ADSR

    │     ╱╲
    │    ╱  ╲_______
    │   ╱           ╲
    │  ╱             ╲
    │ ╱               ╲
────┴───────────────────────
    A   D    S     R
    ↑   ↑    ↑     ↑
   Gate On        Gate Off
StageDescriptionTypical Range
AttackTime to reach peak (0→5V)1ms - 10s
DecayTime to fall to sustain level1ms - 10s
SustainLevel held while gate is high0V - 5V
ReleaseTime to return to zero1ms - 10s

The Mathematics

Each stage is typically an exponential curve:

Attack (exponential rise): $$v(t) = V_{max} \cdot (1 - e^{-t/\tau_a})$$

Decay/Release (exponential fall): $$v(t) = V_{start} \cdot e^{-t/\tau_d}$$

Where $\tau$ is the time constant. Analog envelopes have this natural exponential shape—it’s how capacitors charge and discharge.

Building the Example

//! Tutorial: Envelope Shaping
//!
//! Demonstrates the ADSR envelope generator and how it shapes sound over time.
//! This is fundamental to giving synthesized sounds their character.
//!
//! Run with: cargo run --example tutorial_envelope

use quiver::prelude::*;
use std::sync::Arc;

fn main() {
    let sample_rate = 44100.0;
    let mut patch = Patch::new(sample_rate);

    // Gate control - simulates key press
    let gate_cv = Arc::new(AtomicF64::new(0.0));
    let gate = patch.add("gate", ExternalInput::gate(Arc::clone(&gate_cv)));

    // Sound source
    let vco = patch.add("vco", Vco::new(sample_rate));

    // ADSR envelope generator
    let env = patch.add("env", Adsr::new(sample_rate));

    // Amplifier controlled by envelope
    let vca = patch.add("vca", Vca::new());

    // Output
    let output = patch.add("output", StereoOutput::new());

    // Connections
    patch.connect(gate.out("out"), env.in_("gate")).unwrap();
    patch.connect(vco.out("saw"), vca.in_("in")).unwrap();
    patch.connect(env.out("env"), vca.in_("cv")).unwrap();
    patch.connect(vca.out("out"), output.in_("left")).unwrap();

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

    println!("=== ADSR Envelope Demo ===\n");

    // Helper to get envelope level
    fn run_samples(patch: &mut Patch, n: usize) -> f64 {
        let mut last = 0.0;
        for _ in 0..n {
            let (left, _) = patch.tick();
            last = left.abs();
        }
        last
    }

    // Start with gate off
    println!("Initial state (gate off):");
    let level = run_samples(&mut patch, 100);
    println!("  Envelope level: {:.3}V\n", level);

    // Gate ON - trigger attack
    println!("Gate ON - Attack phase begins");
    gate_cv.set(5.0);

    // Sample the attack
    for ms in [10, 25, 50, 100, 200] {
        let samples = (sample_rate * ms as f64 / 1000.0) as usize;
        let level = run_samples(&mut patch, samples);
        println!("  {}ms: level = {:.2}V", ms, level * 5.0); // scale for display
    }

    // Let it reach sustain
    println!("\nDecay → Sustain:");
    let level = run_samples(&mut patch, (sample_rate * 0.5) as usize);
    println!("  Sustain level: {:.2}V\n", level * 5.0);

    // Gate OFF - trigger release
    println!("Gate OFF - Release phase begins");
    gate_cv.set(0.0);

    for ms in [50, 100, 200, 500] {
        let samples = (sample_rate * ms as f64 / 1000.0) as usize;
        let level = run_samples(&mut patch, samples);
        println!("  +{}ms: level = {:.3}V", ms, level * 5.0);
    }

    println!("\nThe envelope has completed its cycle.");
    println!("Attack→Decay→Sustain (while held) →Release (when released)");
}

Envelope as Modulation Source

The envelope doesn’t just control volume. Route it to:

flowchart TD
    ADSR[ADSR Envelope]
    ADSR -->|brightness| VCF[Filter Cutoff]
    ADSR -->|volume| VCA[Amplifier]
    ADSR -->|depth| FM[FM Amount]
    ADSR -->|color| PWM[Pulse Width]

Filter Envelope

Routing envelope to filter creates the classic “brightness sweep”:

  • Plucky bass: Fast attack, fast decay, low sustain
  • Brass stab: Medium attack, fast decay, medium sustain
  • String pad: Slow attack, slow decay, high sustain

Dual Envelope Routing

Different amounts to different destinations:

DestinationAmountEffect
VCA100%Full volume control
VCF50%Subtle brightness sweep
Pitch5%Pitch “blip” on attack

Musical Applications

Plucky Synth Bass

Attack:  5ms   (instant)
Decay:   200ms (quick fall)
Sustain: 30%   (some body)
Release: 100ms (clean cutoff)

Swelling Pad

Attack:  2s    (slow fade in)
Decay:   500ms (gentle settle)
Sustain: 80%   (full and rich)
Release: 3s    (long tail)

Percussive Hit

Attack:  1ms   (instant)
Decay:   50ms  (very fast)
Sustain: 0%    (no sustain)
Release: 50ms  (immediate)

Envelope Stages Visualization

sequenceDiagram
    participant G as Gate
    participant E as Envelope

    Note over G,E: Note On
    G->>E: Gate HIGH (+5V)
    E->>E: Attack phase (rising)
    E->>E: Decay phase (falling)
    E->>E: Sustain phase (holding)

    Note over G,E: Note Off
    G->>E: Gate LOW (0V)
    E->>E: Release phase (falling to 0)

Next: Filter Modulation

Filter Modulation

Modulation brings patches to life. When we connect an LFO (Low Frequency Oscillator) to the filter cutoff, static becomes dynamic—a still photograph becomes a movie.

flowchart LR
    LFO[LFO<br/>~2Hz] -->|mod| VCF[Filter<br/>Cutoff]
    VCO[VCO] -->|audio| VCF
    VCF --> OUT[Output]

    style LFO fill:#f9a826,color:#000

LFO: The Modulation Source

An LFO is simply an oscillator running at sub-audio rates:

Audio OscillatorLFO
20Hz - 20kHz0.01Hz - 30Hz
Creates pitchCreates movement
You hear itYou feel its effect
graph LR
    subgraph "LFO Waveforms"
        SIN[Sine<br/>Smooth sweep]
        TRI[Triangle<br/>Linear sweep]
        SAW[Saw<br/>Ramp + drop]
        SQR[Square<br/>Two states]
    end

The Mathematics of Modulation

Filter cutoff with LFO modulation:

$$f_c(t) = f_{center} + f_{depth} \cdot \text{LFO}(t)$$

Where:

  • $f_{center}$ is the base cutoff frequency
  • $f_{depth}$ is the modulation depth (how far it sweeps)
  • $\text{LFO}(t)$ oscillates between -1 and +1

Building the Patch

//! Tutorial: Filter Modulation
//!
//! Demonstrates LFO modulation of filter cutoff - the classic "wobble"
//! that brings patches to life.
//!
//! Run with: cargo run --example tutorial_filter_mod

use quiver::prelude::*;

fn main() {
    let sample_rate = 44100.0;
    let mut patch = Patch::new(sample_rate);

    // Sound source - sawtooth oscillator
    let vco = patch.add("vco", Vco::new(sample_rate));

    // LFO for modulation (runs at sub-audio rate)
    let lfo = patch.add("lfo", Lfo::new(sample_rate));

    // Filter - we'll modulate its cutoff
    let vcf = patch.add("vcf", Svf::new(sample_rate));

    // Base cutoff offset
    let cutoff_base = patch.add("cutoff_base", Offset::new(3.0));

    // Output
    let output = patch.add("output", StereoOutput::new());

    // Audio path: VCO → Filter → Output
    patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
    patch.connect(vcf.out("lp"), output.in_("left")).unwrap();

    // Modulation: LFO → Filter cutoff (with base offset)
    patch
        .connect(cutoff_base.out("out"), vcf.in_("cutoff"))
        .unwrap();
    patch.connect(lfo.out("sin"), vcf.in_("fm")).unwrap();

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

    println!("=== Filter Modulation Demo ===\n");
    println!("LFO modulating filter cutoff creates the classic 'wobble' effect.\n");

    // Generate 2 seconds of audio to hear multiple LFO cycles
    let duration = 2.0;
    let total_samples = (sample_rate * duration) as usize;

    // Track the signal envelope over time
    let block_size = (sample_rate / 10.0) as usize; // 100ms blocks
    let mut time = 0.0;

    println!("Time(s)  | Peak Level | Character");
    println!("---------|------------|----------");

    for block in 0..(total_samples / block_size) {
        let mut peak = 0.0_f64;

        for _ in 0..block_size {
            let (left, _) = patch.tick();
            peak = peak.max(left.abs());
        }

        // Describe the sound character based on peak
        let character = if peak > 4.0 {
            "Bright (filter open)"
        } else if peak > 2.0 {
            "Medium"
        } else {
            "Dark (filter closed)"
        };

        if block % 5 == 0 {
            println!("{:7.2}  | {:10.2}V | {}", time, peak, character);
        }

        time += block_size as f64 / sample_rate;
    }

    println!("\nThe LFO creates a periodic sweep of the filter,");
    println!("cycling between bright (open) and dark (closed) states.");
    println!("\nTry different LFO waveforms:");
    println!("  - sin: smooth, natural sweep");
    println!("  - tri: linear ramp up and down");
    println!("  - saw: slow rise, fast drop");
    println!("  - sqr: instant toggle between states");
}

Modulation Depth and Attenuverters

The amount of modulation matters:

DepthEffect
10%Subtle shimmer
25%Noticeable movement
50%Dramatic sweep
100%Extreme wah-wah

Quiver cables support attenuation:

// Connect with 50% modulation depth
patch.connect_with(
    lfo.out("sin"),
    vcf.in_("cutoff"),
    Cable::new().with_attenuation(0.5),
)?;

Waveform Shapes

Each LFO waveform creates a different movement:

Sine Wave

Smooth, natural sweeping—good for gentle effects.

    ╱╲    ╱╲    ╱╲
   ╱  ╲  ╱  ╲  ╱  ╲
──╱────╲╱────╲╱────╲──

Triangle Wave

Linear sweeping—predictable, good for trills.

   ╱╲    ╱╲    ╱╲
  ╱  ╲  ╱  ╲  ╱  ╲
─╱────╲╱────╲╱────╲─

Sawtooth Wave

Rises slowly, drops instantly—creates rhythmic “pumping.”

   ╱│   ╱│   ╱│
  ╱ │  ╱ │  ╱ │
─╱──│─╱──│─╱──│──

Square Wave

Instant alternation between two states—tremolo/vibrato effect.

 ┌──┐  ┌──┐  ┌──┐
 │  │  │  │  │  │
─┘  └──┘  └──┘  └─

Rate and Depth Interaction

quadrantChart
    title LFO Character
    x-axis Slow Rate --> Fast Rate
    y-axis Subtle Depth --> Deep Depth
    quadrant-1 Vibrato/Tremolo
    quadrant-2 Slow Sweep
    quadrant-3 Subtle Texture
    quadrant-4 Frantic Motion
RateDepthClassic Use
0.5Hz30%Slow filter sweep
2Hz10%Subtle shimmer
6Hz50%Dubstep wobble
8Hz5%Guitar vibrato

Multiple Modulation Sources

Combine LFO with envelope for evolving sounds:

flowchart TD
    LFO[LFO<br/>Ongoing movement]
    ENV[Envelope<br/>Per-note shape]
    SUM((Σ))
    VCF[Filter Cutoff]

    LFO --> SUM
    ENV --> SUM
    SUM --> VCF

The envelope provides the initial “brightness burst,” while the LFO adds continuous movement during sustain.


Next: Building a Sequenced Bass

Building a Sequenced Bass

Let’s create something musical: a step sequencer driving a bass synthesizer. This is the foundation of countless electronic music tracks.

flowchart LR
    CLK[Clock] -->|tempo| SEQ[Step<br/>Sequencer]
    SEQ -->|V/Oct| VCO[VCO]
    SEQ -->|gate| ENV[ADSR]
    VCO --> VCF[VCF]
    ENV --> VCF
    ENV --> VCA[VCA]
    VCF --> VCA
    VCA --> OUT[Output]

    style SEQ fill:#e74c3c,color:#fff
    style CLK fill:#50c878,color:#fff

The Step Sequencer

A step sequencer cycles through a series of values, advancing on each clock pulse:

Step:    1    2    3    4    5    6    7    8
CV:     ┌─┐  ┌─┐       ┌─┐  ┌─┐       ┌─┐  ┌─┐
        │ │  │ │       │ │  │ │       │ │  │ │
Gate:   └─┘  └─┘       └─┘  └─┘       └─┘  └─┘
        C3   D3  rest  G3   C3  rest  E3   D3

Each step can have:

  • CV value: The pitch (in V/Oct)
  • Gate: On or off (rest = off)

V/Oct and Musical Pitches

Converting notes to voltages:

NoteMIDIV/Oct
C348-1.0V
C4600.0V
D462+0.167V
E464+0.333V
G467+0.583V
C572+1.0V

The formula:

$$V = \frac{\text{MIDI} - 60}{12}$$

Building the Patch

//! Tutorial: Building a Sequenced Bass
//!
//! A step sequencer driving a classic subtractive bass voice.
//! This pattern is the foundation of house, techno, and many other genres.
//!
//! Run with: cargo run --example tutorial_sequenced_bass

use quiver::prelude::*;

fn main() {
    let sample_rate = 44100.0;
    let mut patch = Patch::new(sample_rate);

    // Master clock - sets the tempo
    let clock = patch.add("clock", Clock::new(sample_rate));

    // Step sequencer - stores our bassline pattern
    let seq = patch.add("seq", StepSequencer::new());

    // Bass voice: VCO → VCF → VCA
    let vco = patch.add("vco", Vco::new(sample_rate));
    let vcf = patch.add("vcf", Svf::new(sample_rate));
    let vca = patch.add("vca", Vca::new());
    let env = patch.add("env", Adsr::new(sample_rate));

    // Output
    let output = patch.add("output", StereoOutput::new());

    // Clock → Sequencer
    patch.connect(clock.out("div_8"), seq.in_("clock")).unwrap();

    // Sequencer → Voice
    patch.connect(seq.out("cv"), vco.in_("voct")).unwrap();
    patch.connect(seq.out("gate"), env.in_("gate")).unwrap();

    // Audio path
    patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
    patch.connect(vcf.out("lp"), vca.in_("in")).unwrap();
    patch.connect(vca.out("out"), output.in_("left")).unwrap();
    patch.connect(vca.out("out"), output.in_("right")).unwrap();

    // Envelope → Filter & VCA
    patch.connect(env.out("env"), vcf.in_("cutoff")).unwrap();
    patch.connect(env.out("env"), vca.in_("cv")).unwrap();

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

    println!("=== Sequenced Bass Demo ===\n");

    // The sequencer has 8 steps with default values
    // In a real application, you'd set the step CVs programmatically

    // Convert MIDI note to V/Oct
    fn midi_to_voct(note: u8) -> f64 {
        (note as f64 - 60.0) / 12.0
    }

    // Our bassline: C3, D3, rest, G2, C3, rest, E3, D3
    let pattern = [
        (48, true), // C3
        (50, true), // D3
        (0, false), // rest
        (43, true), // G2
        (48, true), // C3
        (0, false), // rest
        (52, true), // E3
        (50, true), // D3
    ];

    println!("Bassline pattern:");
    for (i, (note, active)) in pattern.iter().enumerate() {
        if *active {
            let voct = midi_to_voct(*note);
            let note_name = match note % 12 {
                0 => "C",
                1 => "C#",
                2 => "D",
                3 => "D#",
                4 => "E",
                5 => "F",
                6 => "F#",
                7 => "G",
                8 => "G#",
                9 => "A",
                10 => "A#",
                11 => "B",
                _ => "?",
            };
            let octave = (note / 12) - 1;
            println!("  Step {}: {}{} ({:.3}V)", i + 1, note_name, octave, voct);
        } else {
            println!("  Step {}: rest", i + 1);
        }
    }

    println!("\nRunning sequencer for 2 seconds...\n");

    // Run the patch
    let total_samples = (sample_rate * 2.0) as usize;
    let step_samples = total_samples / 16; // ~8 steps at default tempo

    for step in 0..16 {
        let mut peak = 0.0_f64;

        for _ in 0..step_samples {
            let (left, _) = patch.tick();
            peak = peak.max(left.abs());
        }

        let step_num = (step % 8) + 1;
        let bar = "█".repeat((peak * 10.0) as usize);
        println!("Step {}: {:5.2}V |{}", step_num, peak, bar);
    }

    println!("\nThe sequencer cycles through the pattern,");
    println!("triggering the envelope on each gated step.");
}

Clock Divisions

The clock module provides multiple time divisions:

graph TB
    MASTER[Master Clock<br/>120 BPM] --> D1[1/1<br/>Whole notes]
    MASTER --> D2[1/2<br/>Half notes]
    MASTER --> D4[1/4<br/>Quarter notes]
    MASTER --> D8[1/8<br/>Eighth notes]
    MASTER --> D16[1/16<br/>Sixteenth notes]

For a bassline at 120 BPM:

  • 1/8 notes = 4 Hz (classic house tempo)
  • 1/16 notes = 8 Hz (driving techno)

Filter Envelope Relationship

The key to punchy bass is the filter envelope:

Attack:  Fast (5ms)
Decay:   Medium (100-200ms)
Sustain: Low (20-40%)
Release: Quick (50-100ms)

This creates the characteristic “pluck” where brightness fades quickly.

Accent and Dynamics

Real sequences have accents—emphasized notes. Implement with velocity:

sequenceDiagram
    participant SEQ as Sequencer
    participant ENV as Envelope

    SEQ->>ENV: Step 1 (normal)
    Note over ENV: Attack → Sustain

    SEQ->>ENV: Step 2 (accented)
    Note over ENV: Attack → Higher peak<br/>→ Sustain

Classic Patterns

House Bass

Step: 1  2  3  4  5  6  7  8
Note: C  -  C  -  C  -  C  C

The off-beat creates the groove.

Acid (TB-303 Style)

Step: 1  2  3  4  5  6  7  8
Note: C  C  D  -  F  -  D  C
Acc:  X           X
Slide:   →     →

Accents and slides define the style.

Minimal Techno

Step: 1  2  3  4  5  6  7  8
Note: C  -  -  -  C  -  -  -

Space and repetition create hypnotic effect.

Going Further

  • Add slide/portamento with SlewLimiter
  • Randomize steps with BernoulliGate
  • Quantize to scale with Quantizer
  • Layer with detuned second VCO

Next: FM Synthesis Basics

FM Synthesis Basics

Frequency Modulation (FM) synthesis creates complex timbres by modulating one oscillator’s frequency with another. It’s the technology behind the DX7 and countless digital synths.

flowchart LR
    MOD[Modulator<br/>Oscillator] -->|FM| CAR[Carrier<br/>Oscillator]
    CAR --> OUT[Output]

    style MOD fill:#f9a826,color:#000
    style CAR fill:#4a9eff,color:#fff

The Mathematics

In FM synthesis, the carrier frequency is modulated by the modulator:

$$y(t) = A \sin(2\pi f_c t + I \sin(2\pi f_m t))$$

Where:

  • $f_c$ = carrier frequency (the pitch you hear)
  • $f_m$ = modulator frequency
  • $I$ = modulation index (depth)
  • $A$ = amplitude

The modulation index controls harmonic richness:

IndexSound Character
0Pure sine (no modulation)
1-2Warm, mellow
3-5Bright, electric piano-like
6+Harsh, metallic

The Carrier:Modulator Ratio

The frequency ratio determines the harmonic structure:

C:M RatioResult
1:1Symmetric harmonics
1:2Octave-related harmonics
2:1Subharmonics present
1:1.414Inharmonic (bell-like)
1:3.5Metallic, clangorous
graph TD
    subgraph "Harmonic (Musical)"
        H1["1:1, 1:2, 2:3"]
    end
    subgraph "Inharmonic (Percussive)"
        IH["1:1.4, 1:2.7, 1:π"]
    end

Building FM in Quiver

//! Tutorial: FM Synthesis Basics
//!
//! Frequency Modulation synthesis using two oscillators.
//! The modulator's output modulates the carrier's frequency,
//! creating rich, complex timbres from simple sine waves.
//!
//! Run with: cargo run --example tutorial_fm

use quiver::prelude::*;

fn main() {
    let sample_rate = 44100.0;
    let mut patch = Patch::new(sample_rate);

    // Carrier oscillator - this is what we hear
    let carrier = patch.add("carrier", Vco::new(sample_rate));

    // Modulator oscillator - this modulates the carrier's frequency
    let modulator = patch.add("modulator", Vco::new(sample_rate));

    // Modulation index control (depth of FM effect)
    let mod_depth = patch.add("mod_depth", Attenuverter::new());

    // Output
    let output = patch.add("output", StereoOutput::new());

    // FM connection: modulator → carrier's FM input
    patch
        .connect(modulator.out("sin"), mod_depth.in_("in"))
        .unwrap();
    patch
        .connect(mod_depth.out("out"), carrier.in_("fm"))
        .unwrap();

    // Carrier to output (using sine for pure FM demonstration)
    patch
        .connect(carrier.out("sin"), output.in_("left"))
        .unwrap();
    patch
        .connect(carrier.out("sin"), output.in_("right"))
        .unwrap();

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

    println!("=== FM Synthesis Demo ===\n");
    println!("Two oscillators: Carrier (audible) + Modulator (creates harmonics)\n");

    // Generate samples at different modulation depths
    let samples_per_test = (sample_rate * 0.5) as usize;

    // Test different modulation indices
    for (name, ratio, depth) in [
        ("Pure carrier (no FM)", 1.0, 0.0),
        ("Subtle FM (index ~1)", 1.0, 0.5),
        ("Medium FM (index ~3)", 1.0, 1.5),
        ("Heavy FM (index ~5)", 1.0, 2.5),
        ("Bell (1:√2 ratio)", 1.414, 2.0),
        ("Metallic (1:3.5 ratio)", 3.5, 2.0),
    ] {
        // Reset and reconfigure
        let mut test_patch = Patch::new(sample_rate);

        let carrier = test_patch.add("carrier", Vco::new(sample_rate));
        let modulator = test_patch.add("modulator", Vco::new(sample_rate));
        let mod_depth_node = test_patch.add("mod_depth", Attenuverter::new());
        let ratio_mult = test_patch.add("ratio", Attenuverter::new()); // Scale modulator pitch
        let output = test_patch.add("output", StereoOutput::new());

        // Set up FM with the given parameters
        test_patch
            .connect(modulator.out("sin"), mod_depth_node.in_("in"))
            .unwrap();
        test_patch
            .connect(mod_depth_node.out("out"), carrier.in_("fm"))
            .unwrap();
        test_patch
            .connect(carrier.out("sin"), output.in_("left"))
            .unwrap();

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

        // Generate samples
        let mut peak = 0.0_f64;
        let mut zero_crossings = 0;
        let mut last_sign = 0.0_f64;

        for i in 0..samples_per_test {
            let (left, _) = test_patch.tick();
            peak = peak.max(left.abs());

            // Count zero crossings (rough measure of harmonic content)
            if i > 0 {
                let current_sign = if left >= 0.0 { 1.0 } else { -1.0 };
                if current_sign != last_sign {
                    zero_crossings += 1;
                }
                last_sign = current_sign;
            }
        }

        // Zero crossing rate indicates harmonic complexity
        let zcr = zero_crossings as f64 / (samples_per_test as f64 / sample_rate);

        println!("{}", name);
        println!("  C:M ratio = 1:{:.3}, mod depth = {:.1}", ratio, depth);
        println!("  Peak: {:.2}V, Zero-crossing rate: {:.0} Hz", peak, zcr);
        println!();
    }

    println!("FM synthesis creates complex timbres from simple oscillators.");
    println!("The carrier:modulator ratio determines harmonic vs inharmonic sound.");
    println!("The modulation index (depth) controls brightness and complexity.");
}

Sideband Theory

FM creates sidebands around the carrier frequency:

$$f_{sidebands} = f_c \pm n \cdot f_m$$

Where $n = 1, 2, 3, …$

       ▲
       │    ▲
   ▲   │    │   ▲
   │   │    │   │
───┴───┴────┴───┴───
  -2fm -fm  fc  +fm +2fm

The modulation index determines how many sidebands have significant amplitude (roughly $I + 1$ sidebands on each side).

Envelope the Index

The key to expressive FM is modulating the modulation index over time:

flowchart LR
    ENV[Envelope] -->|index| FM((FM<br/>Amount))
    MOD[Modulator] --> FM
    FM --> CAR[Carrier]

A decaying envelope creates the characteristic “bright attack, mellow sustain” of electric pianos.

Classic FM Sounds

Electric Piano (DX7 Style)

Carrier:Modulator = 1:1
Index envelope: Fast attack, medium decay
Starting index: ~5
Ending index: ~1

Brass

Carrier:Modulator = 1:1
Index envelope: Slow attack
Starting index: 2
Peak index: 8

Bell

Carrier:Modulator = 1:1.414 (√2)
Index: 8-10 (constant)
Long release envelope

Bass

Carrier:Modulator = 1:2
Fast index decay
Heavy carrier filtering

FM vs Subtractive

AspectSubtractiveFM
HarmonicsRemove from rich sourceGenerate from sine waves
CPUFilter computationMultiple oscillators
CharacterWarm, analogBright, digital
ControlIntuitiveParameter-sensitive

Stacking Operators

Classic FM synths use 4-6 “operators” (oscillators) in various configurations:

graph TB
    subgraph "Algorithm 1"
        A1[Op1] --> A2[Op2]
        A2 --> OUT1[Out]
    end

    subgraph "Algorithm 2"
        B1[Op1] --> B3[Op3]
        B2[Op2] --> B3
        B3 --> OUT2[Out]
    end

    subgraph "Algorithm 3"
        C1[Op1] --> C2[Op2]
        C1 --> C3[Op3]
        C2 --> OUT3a[Out]
        C3 --> OUT3b[Out]
    end

Each algorithm creates different timbral possibilities.


Next: Polyphonic Patches

Polyphonic Patches

So far we’ve built monophonic (single-voice) patches. Real keyboards need polyphony—multiple simultaneous notes. Quiver provides a complete voice allocation system.

flowchart TB
    MIDI[MIDI Input] --> VA[Voice<br/>Allocator]
    VA --> V1[Voice 1]
    VA --> V2[Voice 2]
    VA --> V3[Voice 3]
    VA --> VN[Voice N]
    V1 --> MIX[Mixer]
    V2 --> MIX
    V3 --> MIX
    VN --> MIX
    MIX --> OUT[Output]

Voice Allocation

When a new note arrives and all voices are busy, which voice should be “stolen”?

StrategyDescriptionBest For
RoundRobinSteal oldest voiceEven wear
QuietestStealSteal softest voiceMinimal artifacts
OldestStealSteal note held longestPredictable
NoStealIgnore new notesPad sounds
HighestPriorityHigh notes steal lowMelodies
LowestPriorityLow notes steal highBass lines

Voice States

Each voice has a lifecycle:

stateDiagram-v2
    [*] --> Free
    Free --> Active: Note On
    Active --> Releasing: Note Off
    Releasing --> Free: Release Complete
    Active --> Active: Retrigger
    Releasing --> Active: Retrigger

Building a Polyphonic Patch

//! Tutorial: Polyphonic Patches
//!
//! Demonstrates voice allocation for playing multiple simultaneous notes.
//! This is essential for keyboard-style synthesizers.
//!
//! Run with: cargo run --example tutorial_polyphony

use quiver::prelude::*;

fn main() {
    let num_voices = 4;

    println!("=== Polyphony Demo ===\n");
    println!("Simulating a {}-voice polyphonic synthesizer\n", num_voices);

    // Create a voice allocator
    let mut allocator = VoiceAllocator::new(num_voices);

    // Helper to convert MIDI note to V/Oct
    fn midi_to_voct(note: u8) -> f64 {
        (note as f64 - 60.0) / 12.0
    }

    fn note_name(note: u8) -> String {
        let names = [
            "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
        ];
        let octave = (note / 12) as i32 - 1;
        format!("{}{}", names[(note % 12) as usize], octave)
    }

    // Simulate playing a chord: C4, E4, G4, B4 (Cmaj7)
    let chord = [60u8, 64, 67, 71]; // C4, E4, G4, B4

    println!("Playing Cmaj7 chord:");
    for &note in &chord {
        if let Some(voice_idx) = allocator.note_on(note, 0.8) {
            println!(
                "  {} (MIDI {}) → Voice {}, V/Oct = {:.3}V",
                note_name(note),
                note,
                voice_idx,
                midi_to_voct(note)
            );
        } else {
            println!(
                "  {} (MIDI {}) → No voice available!",
                note_name(note),
                note
            );
        }
    }

    // Show voice states
    println!("\nVoice states after chord:");
    for i in 0..num_voices {
        if let Some(voice) = allocator.voice(i) {
            match voice.state {
                VoiceState::Active => {
                    if let Some(note) = voice.note {
                        println!(
                            "  Voice {}: Active, playing {} (V/Oct: {:.3}V)",
                            i,
                            note_name(note),
                            voice.voct
                        );
                    }
                }
                VoiceState::Free => println!("  Voice {}: Free", i),
                VoiceState::Releasing => println!("  Voice {}: Releasing", i),
            }
        }
    }

    // Now try to play another note - will steal!
    println!("\nPlaying D5 (MIDI 74) - all voices busy, must steal:");
    if let Some(stolen_voice) = allocator.note_on(74, 0.9) {
        println!(
            "  D5 assigned to Voice {} (stolen from previous note)",
            stolen_voice
        );
    } else {
        println!("  D5 could not be allocated (NoSteal mode)");
    }

    // Show updated states
    println!("\nVoice states after steal:");
    for i in 0..num_voices {
        if let Some(voice) = allocator.voice(i) {
            match voice.state {
                VoiceState::Active => {
                    if let Some(note) = voice.note {
                        println!(
                            "  Voice {}: Active, playing {} (V/Oct: {:.3}V)",
                            i,
                            note_name(note),
                            voice.voct
                        );
                    }
                }
                VoiceState::Free => println!("  Voice {}: Free", i),
                VoiceState::Releasing => println!("  Voice {}: Releasing", i),
            }
        }
    }

    // Release some notes
    println!("\nReleasing E4 and G4:");
    allocator.note_off(64); // E4
    allocator.note_off(67); // G4

    println!("\nVoice states after release:");
    for i in 0..num_voices {
        if let Some(voice) = allocator.voice(i) {
            match voice.state {
                VoiceState::Active => {
                    if let Some(note) = voice.note {
                        println!("  Voice {}: Active, {}", i, note_name(note));
                    }
                }
                VoiceState::Free => println!("  Voice {}: Free", i),
                VoiceState::Releasing => {
                    if let Some(note) = voice.note {
                        println!("  Voice {}: Releasing (was {})", i, note_name(note));
                    }
                }
            }
        }
    }

    // Demonstrate different allocation modes
    println!("\n--- Allocation Modes ---\n");

    for mode in [
        AllocationMode::RoundRobin,
        AllocationMode::QuietestSteal,
        AllocationMode::OldestSteal,
        AllocationMode::NoSteal,
        AllocationMode::HighestPriority,
        AllocationMode::LowestPriority,
    ] {
        let mode_name = match mode {
            AllocationMode::RoundRobin => "RoundRobin",
            AllocationMode::QuietestSteal => "QuietestSteal",
            AllocationMode::OldestSteal => "OldestSteal",
            AllocationMode::NoSteal => "NoSteal",
            AllocationMode::HighestPriority => "HighestPriority",
            AllocationMode::LowestPriority => "LowestPriority",
        };

        let desc = match mode {
            AllocationMode::RoundRobin => "Cycles through voices in order",
            AllocationMode::QuietestSteal => "Steals the voice with lowest envelope",
            AllocationMode::OldestSteal => "Steals the note held longest",
            AllocationMode::NoSteal => "Ignores new notes when full",
            AllocationMode::HighestPriority => "Higher notes can steal lower",
            AllocationMode::LowestPriority => "Lower notes can steal higher",
        };

        println!("{}: {}", mode_name, desc);
    }

    println!("\nPolyphony enables expressive keyboard playing and chord voicings.");
}

Per-Voice Signals

Each voice receives its own:

  • V/Oct pitch — from the played note
  • Gate — high while key held
  • Trigger — pulse at note start
  • Velocity — key strike strength
flowchart LR
    VA[Voice Allocator]
    VA -->|voct| VCO[VCO]
    VA -->|gate| ENV[ADSR]
    VA -->|velocity| VCA[Velocity VCA]

Unison and Detune

For thicker sounds, stack multiple oscillators per voice:

let config = UnisonConfig::new(3)  // 3 oscillators per voice
    .with_detune(0.1)              // Slight detune between them
    .with_spread(0.5);             // Stereo spread

The slight detuning creates a chorus-like richness.

MIDI Note to V/Oct

Quiver uses the standard conversion:

$$V_{oct} = \frac{\text{MIDI} - 60}{12}$$

MIDI NoteNameV/Oct
48C3-1.0V
60C40.0V
72C5+1.0V
84C6+2.0V

Helper function:

fn midi_note_to_voct(note: u8) -> f64 {
    (note as f64 - 60.0) / 12.0
}

Voice Stealing in Action

sequenceDiagram
    participant K as Keyboard
    participant VA as Allocator
    participant V1 as Voice 1
    participant V2 as Voice 2

    K->>VA: C4 Note On
    VA->>V1: Assign C4
    Note over V1: Playing C4

    K->>VA: E4 Note On
    VA->>V2: Assign E4
    Note over V1,V2: Playing C4 + E4

    K->>VA: G4 Note On (voices full)
    VA->>V1: Steal, assign G4
    Note over V1: Now playing G4
    Note over V2: Still playing E4

Legato Mode

For lead sounds, you might want legato: new notes don’t retrigger the envelope if a previous note is held.

sequenceDiagram
    participant K as Keys
    participant E as Envelope

    K->>E: C4 on
    Note over E: Attack→Sustain

    K->>E: D4 on (C4 still held)
    Note over E: Pitch slides, no retrigger

    K->>E: C4 off, D4 still held
    Note over E: Sustain continues

    K->>E: D4 off
    Note over E: Release

Performance Considerations

Polyphony multiplies CPU usage:

  • 8 voices × 4 oscillators = 32 oscillators
  • Each voice has its own filter, envelope, etc.

Quiver’s block processing helps:

// Process multiple samples at once
let mut block = AudioBlock::new();
for voice in voices.iter_mut() {
    voice.process_block(&mut block);
}

That concludes the Tutorials section. Next, explore How-To Guides for task-focused recipes.

Connect Modules

This guide covers all the ways to connect modules in Quiver, from basic patching to advanced cable configuration.

Basic Connection

The fundamental operation:

patch.connect(source.out("output_name"), dest.in_("input_name"))?;

Note: Use in_() because in is a Rust reserved word.

Finding Port Names

Check the module’s PortSpec:

let vco = Vco::new(44100.0);
let spec = vco.port_spec();

println!("Inputs: {:?}", spec.inputs.keys().collect::<Vec<_>>());
println!("Outputs: {:?}", spec.outputs.keys().collect::<Vec<_>>());

Common port names:

ModuleInputsOutputs
Vcovoct, fm, pw, syncsin, tri, saw, sqr
Svfin, cutoff, resonance, fmlp, bp, hp, notch
Adsrgate, attack, decay, sustain, releaseenv
Vcain, cvout

Connection with Attenuation

Scale the signal strength:

patch.connect_with(
    lfo.out("sin"),
    vcf.in_("cutoff"),
    Cable::new().with_attenuation(0.5),  // 50% strength
)?;

Attenuation range: -2.0 to +2.0

  • 1.0 = unity (no change)
  • 0.5 = half strength
  • -1.0 = inverted
  • 2.0 = doubled (with clipping risk)

Connection with Offset

Add a DC offset to the signal:

patch.connect_with(
    lfo.out("sin"),
    vcf.in_("cutoff"),
    Cable::new()
        .with_attenuation(0.3)
        .with_offset(5.0),  // Center at 5V
)?;

This shifts the LFO’s ±5V swing to oscillate around 5V.

Multiple Outputs (Mult)

One output can feed multiple inputs:

// Same gate triggers multiple envelopes
patch.connect(gate.out("out"), env1.in_("gate"))?;
patch.connect(gate.out("out"), env2.in_("gate"))?;
patch.connect(gate.out("out"), env3.in_("gate"))?;

Quiver handles the signal distribution automatically.

Multiple Inputs (Summing)

Multiple cables to one input are summed:

// Two LFOs combined on filter cutoff
patch.connect(lfo1.out("sin"), vcf.in_("cutoff"))?;
patch.connect(lfo2.out("tri"), vcf.in_("cutoff"))?;
// Result: filter cutoff receives lfo1 + lfo2

This models analog behavior where CVs mix at the input.

Validation Modes

Control how signal type mismatches are handled:

// Strict: error on mismatch
patch.set_validation_mode(ValidationMode::Strict);

// Warn: log warning but allow
patch.set_validation_mode(ValidationMode::Warn);

// None: no checking
patch.set_validation_mode(ValidationMode::None);

Default is Warn, which helps catch mistakes without blocking experimentation.

Disconnecting

Remove a specific connection:

let cable_id = patch.connect(vco.out("saw"), vcf.in_("in"))?;
patch.disconnect(cable_id);

Or disconnect by ports:

patch.disconnect_port(vcf.in_("in"));  // Remove all cables to this input

Error Handling

Connection can fail for several reasons:

match patch.connect(a.out("x"), b.in_("y")) {
    Ok(cable_id) => println!("Connected: {:?}", cable_id),
    Err(PatchError::PortNotFound(port)) => {
        println!("Port '{}' doesn't exist", port);
    }
    Err(PatchError::CycleDetected) => {
        println!("Would create a feedback loop");
    }
    Err(e) => println!("Error: {:?}", e),
}

Checking Connections

Query existing cables:

// All cables in patch
for cable in patch.cables() {
    println!("{:?} → {:?}", cable.from, cable.to);
}

// Cables to a specific input
for cable in patch.cables_to(vcf.in_("cutoff")) {
    println!("Modulated by: {:?}", cable.from);
}

Best Practices

  1. Name modules clearly: "filter_lfo" not "lfo2"
  2. Use validation mode Warn during development
  3. Check port specs if unsure about names
  4. Apply attenuation rather than amplification to avoid clipping

Use External MIDI

Connect your Quiver patches to MIDI keyboards, controllers, and DAWs.

The AtomicF64 Bridge

MIDI events arrive on a separate thread. Use AtomicF64 for thread-safe communication:

use std::sync::Arc;
use quiver::prelude::*;

// Create shared atomic values
let pitch = Arc::new(AtomicF64::new(0.0));  // V/Oct
let gate = Arc::new(AtomicF64::new(0.0));   // Gate signal
let mod_wheel = Arc::new(AtomicF64::new(0.0)); // CC1

// Clone for MIDI thread
let pitch_midi = Arc::clone(&pitch);
let gate_midi = Arc::clone(&gate);

ExternalInput Module

Inject atomic values into your patch:

let pitch_in = patch.add("pitch", ExternalInput::voct(Arc::clone(&pitch)));
let gate_in = patch.add("gate", ExternalInput::gate(Arc::clone(&gate)));

patch.connect(pitch_in.out("out"), vco.in_("voct"))?;
patch.connect(gate_in.out("out"), env.in_("gate"))?;

ExternalInput variants:

Factory MethodSignal TypeRange
::voct()V/Oct pitch±10V
::gate()Gate0-5V
::trigger()Trigger0-5V
::cv()Unipolar CV0-10V
::cv_bipolar()Bipolar CV±5V

MIDI to V/Oct Conversion

Standard conversion:

fn midi_note_to_voct(note: u8) -> f64 {
    (note as f64 - 60.0) / 12.0
}

// In your MIDI handler:
pitch_midi.set(midi_note_to_voct(note_number));
MIDI NoteV/Oct
36 (C2)-2.0V
48 (C3)-1.0V
60 (C4)0.0V
72 (C5)+1.0V

MidiState Helper

For more complete MIDI handling:

let midi_state = MidiState::new();

// In MIDI callback:
midi_state.note_on(60, 100);  // Note 60, velocity 100
midi_state.note_off(60);
midi_state.control_change(1, 64);  // Mod wheel to 50%

// Read current state:
let current_voct = midi_state.voct();
let current_gate = midi_state.gate();
let mod_value = midi_state.cc(1);

Example: Complete MIDI Integration

//! How-To: MIDI Input Integration
//!
//! Demonstrates connecting external MIDI to a Quiver patch using AtomicF64
//! for thread-safe communication between MIDI and audio threads.
//!
//! Run with: cargo run --example howto_midi

use quiver::prelude::*;
use std::sync::Arc;

fn main() {
    let sample_rate = 44100.0;

    // Thread-safe communication channels
    let pitch_cv = Arc::new(AtomicF64::new(0.0)); // V/Oct
    let gate_cv = Arc::new(AtomicF64::new(0.0)); // Gate
    let velocity_cv = Arc::new(AtomicF64::new(5.0)); // Velocity (0-10V)
    let mod_wheel_cv = Arc::new(AtomicF64::new(0.0)); // CC1 modulation

    // Create patch
    let mut patch = Patch::new(sample_rate);

    // External inputs
    let pitch = patch.add("midi_pitch", ExternalInput::voct(Arc::clone(&pitch_cv)));
    let gate = patch.add("midi_gate", ExternalInput::gate(Arc::clone(&gate_cv)));
    let velocity = patch.add("midi_vel", ExternalInput::cv(Arc::clone(&velocity_cv)));
    let mod_wheel = patch.add("mod_wheel", ExternalInput::cv(Arc::clone(&mod_wheel_cv)));

    // Synth voice
    let vco = patch.add("vco", Vco::new(sample_rate));
    let vcf = patch.add("vcf", Svf::new(sample_rate));
    let vca = patch.add("vca", Vca::new());
    let env = patch.add("env", Adsr::new(sample_rate));
    let output = patch.add("output", StereoOutput::new());

    // MIDI → synth connections
    patch.connect(pitch.out("out"), vco.in_("voct")).unwrap();
    patch.connect(gate.out("out"), env.in_("gate")).unwrap();

    // Audio chain
    patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
    patch.connect(vcf.out("lp"), vca.in_("in")).unwrap();
    patch.connect(vca.out("out"), output.in_("left")).unwrap();
    patch.connect(vca.out("out"), output.in_("right")).unwrap();

    // Modulation routing
    patch.connect(env.out("env"), vcf.in_("cutoff")).unwrap();
    patch.connect(env.out("env"), vca.in_("cv")).unwrap();
    patch.connect(mod_wheel.out("out"), vcf.in_("fm")).unwrap(); // Mod wheel → filter

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

    println!("=== MIDI Integration Demo ===\n");

    // Simulate MIDI events (in real app, these come from MIDI callback)
    fn midi_note_to_voct(note: u8) -> f64 {
        (note as f64 - 60.0) / 12.0
    }

    fn midi_velocity_to_cv(velocity: u8) -> f64 {
        velocity as f64 / 127.0 * 10.0
    }

    fn midi_cc_to_cv(value: u8) -> f64 {
        value as f64 / 127.0 * 10.0
    }

    // Simulate playing a C4 note
    println!("Simulating MIDI Note On: C4 (60), velocity 100");
    pitch_cv.set(midi_note_to_voct(60));
    velocity_cv.set(midi_velocity_to_cv(100));
    gate_cv.set(5.0); // Gate high

    // Process some samples during note
    let attack_samples = (sample_rate * 0.3) as usize;
    for _ in 0..attack_samples {
        patch.tick();
    }
    println!("  Attack phase processed ({} samples)", attack_samples);

    // Simulate mod wheel movement
    println!("\nSimulating CC1 (Mod Wheel): 64");
    mod_wheel_cv.set(midi_cc_to_cv(64));

    // More processing
    for _ in 0..(sample_rate * 0.2) as usize {
        patch.tick();
    }

    // Simulate note off
    println!("\nSimulating MIDI Note Off");
    gate_cv.set(0.0); // Gate low

    // Process release
    let release_samples = (sample_rate * 0.5) as usize;
    for _ in 0..release_samples {
        patch.tick();
    }
    println!("  Release phase processed ({} samples)", release_samples);

    // Play a chord (demonstrating polyphony would need PolyPatch)
    println!("\n--- Playing ascending notes ---");
    for (note, name) in [(60, "C4"), (64, "E4"), (67, "G4"), (72, "C5")] {
        // Note on
        pitch_cv.set(midi_note_to_voct(note));
        gate_cv.set(5.0);

        // Play for 200ms
        let mut peak = 0.0_f64;
        for _ in 0..(sample_rate * 0.2) as usize {
            let (left, _) = patch.tick();
            peak = peak.max(left.abs());
        }

        // Note off
        gate_cv.set(0.0);
        for _ in 0..(sample_rate * 0.1) as usize {
            patch.tick();
        }

        println!(
            "  {} (MIDI {}): V/Oct = {:.3}V, peak = {:.2}V",
            name,
            note,
            midi_note_to_voct(note),
            peak
        );
    }

    println!("\nMIDI integration complete.");
    println!("In a real application:");
    println!("  1. Create AtomicF64 values for each MIDI parameter");
    println!("  2. Update them from your MIDI callback");
    println!("  3. The audio thread reads the latest values each tick");
}

Gate vs Trigger

sequenceDiagram
    participant K as Keyboard
    participant G as Gate
    participant T as Trigger

    K->>G: Key Down
    G->>G: Goes HIGH (+5V)
    T->>T: Brief pulse (5ms)

    Note over G: Stays HIGH while held

    K->>G: Key Up
    G->>G: Goes LOW (0V)
  • Gate: Stays high while key held (for sustain)
  • Trigger: Brief pulse at note start (for percussion)

Velocity Mapping

Convert MIDI velocity (0-127) to CV:

fn velocity_to_cv(velocity: u8) -> f64 {
    velocity as f64 / 127.0 * 10.0  // 0-10V range
}

Route to VCA for dynamics:

let velocity_in = patch.add("vel", ExternalInput::cv(vel_atomic));
patch.connect(velocity_in.out("out"), vca.in_("cv"))?;

Pitch Bend

Pitch bend is typically ±2 semitones:

fn pitch_bend_to_voct(bend: i16) -> f64 {
    // bend: -8192 to +8191
    // Result: ±2 semitones = ±(2/12) V = ±0.167V
    (bend as f64 / 8192.0) * (2.0 / 12.0)
}

Sum with note pitch:

let total_pitch = note_voct + bend_voct;
pitch_atomic.set(total_pitch);

Thread Safety Notes

  • AtomicF64 uses relaxed ordering—fine for audio
  • Updates are lock-free (no blocking)
  • Read the latest value, never stale data

Serialize and Save Patches

Save patches to JSON and reload them—essential for presets and patch management.

Basic Serialization

Convert a patch to JSON:

// Create your patch
let mut patch = Patch::new(44100.0);
// ... add modules and connections ...

// Serialize to PatchDef
let def = patch.to_def("My Awesome Synth");

// Convert to JSON string
let json = def.to_json()?;
println!("{}", json);

PatchDef Structure

The serialized format:

{
  "version": 1,
  "name": "My Awesome Synth",
  "author": "Your Name",
  "description": "A warm analog-style bass",
  "tags": ["bass", "analog", "subtractive"],
  "modules": [
    {
      "name": "vco",
      "module_type": "vco",
      "position": [100, 200],
      "state": null
    }
  ],
  "cables": [
    {
      "from": "vco.saw",
      "to": "vcf.in",
      "attenuation": 1.0
    }
  ],
  "parameters": {}
}

Loading Patches

Reconstruct a patch from JSON:

// Parse JSON
let def = PatchDef::from_json(&json_string)?;

// Create module registry
let registry = ModuleRegistry::new();

// Rebuild patch
let patch = Patch::from_def(&def, &registry, 44100.0)?;

The Module Registry

The registry maps type names to constructors:

let mut registry = ModuleRegistry::new();

// Built-in modules are registered by default
// For custom modules, register them:
registry.register("my_module", |sr| {
    Box::new(MyCustomModule::new(sr))
});

Default registered modules:

Type IDModule
vcoVco
svfSvf
adsrAdsr
vcaVca
lfoLfo
mixerMixer
stereo_outputStereoOutput
(many more)

File Operations

Save to and load from files:

use std::fs;

// Save
let json = patch.to_def("My Patch").to_json()?;
fs::write("my_patch.json", &json)?;

// Load
let json = fs::read_to_string("my_patch.json")?;
let def = PatchDef::from_json(&json)?;
let patch = Patch::from_def(&def, &registry, 44100.0)?;

Handling External Inputs

ExternalInput modules require Arc<AtomicF64> values that can’t serialize:

// These modules won't round-trip through JSON:
let pitch = patch.add("pitch", ExternalInput::voct(pitch_arc));

// After loading, you'll need to reconnect external inputs manually

Solution: Use Offset for static values, or re-add ExternalInputs after loading.

Patch Metadata

Add descriptive information:

let mut def = patch.to_def("Fat Bass");
def.author = Some("Sound Designer".to_string());
def.description = Some("Classic Moog-style bass with filter sweep".to_string());
def.tags = vec!["bass".into(), "moog".into(), "classic".into()];

Versioning

The version field enables migration:

let def = PatchDef::from_json(&json)?;

match def.version {
    1 => { /* current format */ }
    0 => { /* legacy format - migrate */ }
    _ => { /* unknown version */ }
}

Preset Library

Use the built-in preset system:

let library = PresetLibrary::new();

// List all presets
for preset in library.list() {
    println!("{}: {}", preset.name, preset.description);
}

// Get presets by category
let basses = library.by_category(PresetCategory::Bass);

// Search by tag
let acid = library.search_tags(&["acid", "303"]);

// Load a preset
let preset = library.get("303 Acid")?;
let patch = preset.build(44100.0)?;

Example: Patch Manager

//! How-To: Serialize and Save Patches
//!
//! Demonstrates saving patches to JSON and loading them back.
//! Essential for preset management and patch storage.
//!
//! Run with: cargo run --example howto_serialization

use quiver::prelude::*;

fn main() {
    let sample_rate = 44100.0;

    println!("=== Patch Serialization Demo ===\n");

    // Build a patch
    let mut patch = Patch::new(sample_rate);

    let vco = patch.add("vco", Vco::new(sample_rate));
    let vcf = patch.add("vcf", Svf::new(sample_rate));
    let vca = patch.add("vca", Vca::new());
    let env = patch.add("env", Adsr::new(sample_rate));
    let lfo = patch.add("lfo", Lfo::new(sample_rate));
    let output = patch.add("output", StereoOutput::new());

    // Audio path
    patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
    patch.connect(vcf.out("lp"), vca.in_("in")).unwrap();
    patch.connect(vca.out("out"), output.in_("left")).unwrap();
    patch.connect(vca.out("out"), output.in_("right")).unwrap();

    // Modulation
    patch.connect(env.out("env"), vcf.in_("cutoff")).unwrap();
    patch.connect(env.out("env"), vca.in_("cv")).unwrap();
    patch.connect(lfo.out("sin"), vcf.in_("fm")).unwrap();

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

    println!(
        "Original patch: {} modules, {} cables\n",
        patch.node_count(),
        patch.cable_count()
    );

    // Serialize to JSON
    let mut def = patch.to_def("Warm Pad");
    def.author = Some("Quiver Documentation".to_string());
    def.description = Some("A warm pad with LFO filter modulation".to_string());
    def.tags = vec!["pad".into(), "warm".into(), "modulated".into()];

    let json = def.to_json().expect("Serialization failed");

    println!("--- Serialized JSON ---");
    println!("{}\n", json);

    // Deserialize and rebuild
    println!("--- Deserializing ---");
    let loaded_def = PatchDef::from_json(&json).expect("Deserialization failed");

    println!("Loaded patch: {}", loaded_def.name);
    println!("  Author: {:?}", loaded_def.author);
    println!("  Description: {:?}", loaded_def.description);
    println!("  Tags: {:?}", loaded_def.tags);
    println!("  Modules: {}", loaded_def.modules.len());
    println!("  Cables: {}", loaded_def.cables.len());

    // Rebuild the patch using the registry
    let registry = ModuleRegistry::new();
    let mut reloaded_patch =
        Patch::from_def(&loaded_def, &registry, sample_rate).expect("Failed to rebuild patch");

    println!(
        "\nRebuilt patch: {} modules, {} cables",
        reloaded_patch.node_count(),
        reloaded_patch.cable_count()
    );

    // Verify it works by generating audio
    println!("\n--- Testing reloaded patch ---");

    let mut peak = 0.0_f64;
    for _ in 0..(sample_rate * 0.5) as usize {
        let (left, _) = reloaded_patch.tick();
        peak = peak.max(left.abs());
    }

    println!("Generated 0.5s of audio, peak: {:.2}V", peak);
    println!("\nRound-trip serialization successful!");

    // Show available presets using static methods
    println!("\n--- Built-in Presets ---");

    // Get all presets
    let all_presets = PresetLibrary::list();
    println!("\nTotal available presets: {}", all_presets.len());

    // Filter by category using static method
    println!("\nBass presets:");
    for preset in PresetLibrary::by_category(PresetCategory::Bass) {
        let desc = if preset.description.is_empty() {
            "No description"
        } else {
            &preset.description
        };
        println!("  {} - {}", preset.name, desc);
    }

    println!("\nPad presets:");
    for preset in PresetLibrary::by_category(PresetCategory::Pad) {
        let desc = if preset.description.is_empty() {
            "No description"
        } else {
            &preset.description
        };
        println!("  {} - {}", preset.name, desc);
    }

    println!("\nLead presets:");
    for preset in PresetLibrary::by_category(PresetCategory::Lead) {
        let desc = if preset.description.is_empty() {
            "No description"
        } else {
            &preset.description
        };
        println!("  {} - {}", preset.name, desc);
    }
}

Best Practices

  1. Version your patches: Include version numbers for future compatibility
  2. Document parameters: Use description fields liberally
  3. Test round-trips: Verify patches load correctly after saving
  4. Handle missing modules: Gracefully handle unknown module types
  5. Separate external I/O: Document which external connections are needed

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

Visualize Your Patch

Quiver provides tools to visualize patch topology and analyze signals.

DOT/GraphViz Export

Generate visual diagrams of your patch:

use quiver::prelude::*;

let patch = /* your patch */;

// Create exporter with default style
let exporter = DotExporter::new(&patch);
let dot = exporter.to_dot();

println!("{}", dot);

Save to file and render:

# Save DOT output
cargo run > patch.dot

# Render with GraphViz
dot -Tpng patch.dot -o patch.png
dot -Tsvg patch.dot -o patch.svg

Styling Options

Customize the visualization:

let style = DotStyle::new()
    .with_theme("dark")          // dark, light, minimal
    .with_rankdir("LR")          // LR (left-right) or TB (top-bottom)
    .with_show_port_names(true)
    .with_signal_colors(true);   // Color-code by signal type

let exporter = DotExporter::with_style(&patch, style);

Signal type colors:

  • Audio: Blue
  • CV: Orange
  • Gate/Trigger: Green
  • V/Oct: Red

Example Output

flowchart LR
    subgraph Oscillators
        VCO[VCO]
        LFO[LFO]
    end

    subgraph Processing
        VCF[VCF]
        VCA[VCA]
    end

    subgraph Envelope
        ADSR[ADSR]
    end

    VCO -->|saw| VCF
    LFO -->|sin| VCF
    VCF -->|lp| VCA
    ADSR -->|env| VCF
    ADSR -->|env| VCA
    VCA --> Output

    style VCO fill:#4a9eff
    style LFO fill:#f9a826
    style ADSR fill:#50c878

Oscilloscope

Monitor signals in real-time:

let scope = Scope::new(44100.0)
    .with_buffer_size(1024)
    .with_trigger_mode(TriggerMode::RisingEdge);

// In your audio loop
let sample = patch.tick().0;
scope.write(sample);

// Get waveform for display
let waveform = scope.buffer();

Trigger modes:

  • Free: Continuous display
  • RisingEdge: Trigger on positive zero-crossing
  • FallingEdge: Trigger on negative zero-crossing
  • Single: One-shot capture

Spectrum Analyzer

View frequency content:

let analyzer = SpectrumAnalyzer::new(44100.0);

// Feed samples
for sample in samples.iter() {
    analyzer.write(*sample);
}

// Get spectrum data
let bins = analyzer.bins();        // Frequency bins
let magnitudes = analyzer.magnitudes();  // dB values

// Find dominant frequency
let peak_freq = analyzer.peak_frequency();
println!("Fundamental: {:.1} Hz", peak_freq);

Level Meter

Monitor audio levels:

let mut meter = LevelMeter::new(44100.0)
    .with_peak_hold(500.0);  // 500ms peak hold

// Process samples
for sample in samples.iter() {
    meter.write(*sample);
}

println!("RMS: {:.1} dB", meter.rms_db());
println!("Peak: {:.1} dB", meter.peak_db());

Automation Recording

Record parameter changes:

let mut recorder = AutomationRecorder::new();

// Create a track for filter cutoff
let track_id = recorder.create_track("filter_cutoff");

// Record automation points
recorder.record(track_id, 0.0, 0.5);    // Time 0s: 0.5
recorder.record(track_id, 1.0, 0.8);    // Time 1s: 0.8
recorder.record(track_id, 2.0, 0.2);    // Time 2s: 0.2

// Get automation data
let data = recorder.data();
let json = serde_json::to_string(&data)?;

Example: Complete Visualization

//! How-To: Visualize Your Patch
//!
//! Demonstrates patch visualization including DOT export,
//! signal analysis, and metering.
//!
//! Run with: cargo run --example howto_visualization

use quiver::prelude::*;

fn main() {
    let sample_rate = 44100.0;

    println!("=== Patch Visualization Demo ===\n");

    // Build a patch to visualize
    let mut patch = Patch::new(sample_rate);

    let vco = patch.add("vco", Vco::new(sample_rate));
    let lfo = patch.add("lfo", Lfo::new(sample_rate));
    let vcf = patch.add("vcf", Svf::new(sample_rate));
    let vca = patch.add("vca", Vca::new());
    let env = patch.add("env", Adsr::new(sample_rate));
    let output = patch.add("output", StereoOutput::new());

    // Connections
    patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
    patch.connect(lfo.out("sin"), vcf.in_("fm")).unwrap();
    patch.connect(vcf.out("lp"), vca.in_("in")).unwrap();
    patch.connect(env.out("env"), vcf.in_("cutoff")).unwrap();
    patch.connect(env.out("env"), vca.in_("cv")).unwrap();
    patch.connect(vca.out("out"), output.in_("left")).unwrap();
    patch.connect(vca.out("out"), output.in_("right")).unwrap();

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

    // Generate DOT visualization
    println!("--- DOT Graph Output ---");
    println!("(Save this to a .dot file and render with GraphViz)\n");

    let style = DotStyle::default();
    let dot = DotExporter::export(&patch, &style);
    println!("{}", dot);

    // Generate audio for analysis
    println!("\n--- Signal Analysis ---\n");

    // Collect samples
    let num_samples = (sample_rate * 0.5) as usize;
    let mut samples = Vec::with_capacity(num_samples);

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

    // Basic statistics
    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();
    let dc_offset = samples.iter().sum::<f64>() / num_samples as f64;

    println!("Sample Statistics:");
    println!("  Samples: {}", num_samples);
    println!(
        "  Peak: {:.3}V ({:.1} dB)",
        peak,
        20.0 * (peak / 5.0).log10()
    );
    println!("  RMS: {:.3}V ({:.1} dB)", rms, 20.0 * (rms / 5.0).log10());
    println!("  DC Offset: {:.6}V", dc_offset);

    // Estimate frequency via zero crossings
    let mut zero_crossings = 0;
    for i in 1..samples.len() {
        if (samples[i] >= 0.0) != (samples[i - 1] >= 0.0) {
            zero_crossings += 1;
        }
    }
    let estimated_freq = zero_crossings as f64 / 2.0 / (num_samples as f64 / sample_rate);
    println!("  Estimated Frequency: {:.1} Hz", estimated_freq);

    // ASCII waveform visualization
    println!("\n--- Waveform (ASCII) ---\n");

    let display_samples = 80; // Characters wide
    let step = samples.len() / display_samples;

    for row in (0..11).rev() {
        let threshold = (row as f64 - 5.0) / 5.0 * peak;
        let mut line = String::new();

        for col in 0..display_samples {
            let sample = samples[col * step];
            if (sample >= threshold && row > 5) || (sample <= threshold && row < 5) {
                line.push('█');
            } else if row == 5 {
                line.push('─');
            } else {
                line.push(' ');
            }
        }

        let label = match row {
            10 => "+peak",
            5 => "  0V ",
            0 => "-peak",
            _ => "     ",
        };

        println!("{} |{}", label, line);
    }

    // Using the Scope module
    println!("\n--- Scope Analysis ---\n");

    let mut scope = Scope::new(1024); // Buffer size in samples

    // Recreate patch for fresh samples
    patch.compile().unwrap();

    // Fill scope buffer
    for _ in 0..1024 {
        let (left, _) = patch.tick();
        scope.tick(left);
    }

    let buffer = scope.buffer_vec();
    println!("Scope buffer size: {} samples", buffer.len());

    // Using LevelMeter
    println!("\n--- Level Meter ---\n");

    let mut meter = LevelMeter::new(sample_rate);

    for _ in 0..(sample_rate * 0.1) as usize {
        let (left, _) = patch.tick();
        meter.tick(left);
    }

    println!("Level Meter:");
    println!("  RMS Level: {:.2} dB", meter.rms());
    println!("  Peak Level: {:.2} dB", meter.peak());

    // Module graph summary
    println!("\n--- Patch Summary ---\n");
    println!("Modules: {}", patch.node_count());
    println!("Cables: {}", patch.cable_count());
    println!("\nTo visualize graphically:");
    println!("  1. Save the DOT output above to 'patch.dot'");
    println!("  2. Run: dot -Tpng patch.dot -o patch.png");
    println!("  3. Open patch.png in an image viewer");
}

Integration with GUIs

The visualization data is designed for easy GUI integration:

// For immediate-mode GUIs (egui, imgui)
for (i, magnitude) in analyzer.magnitudes().enumerate() {
    let freq = i as f64 * sample_rate / fft_size;
    draw_bar(freq, magnitude);
}

// For retained-mode GUIs
let path: Vec<(f64, f64)> = scope.buffer()
    .enumerate()
    .map(|(i, sample)| (i as f64, *sample))
    .collect();
draw_path(&path);

Browser & App Integration

This guide explains how to integrate Quiver into your browser application using the WASM bindings and npm packages.

Overview

Quiver provides three npm packages for browser integration:

PackagePurpose
@quiver/wasmCore WASM engine and AudioWorklet utilities
@quiver/reactReact hooks for UI integration
@quiver/typesTypeScript type definitions

Installation

npm install @quiver/wasm @quiver/react

Initializing the Engine

The WASM module must be initialized before use:

import { initWasm, createEngine } from '@quiver/wasm';

// Initialize once at app startup
await initWasm();

// Create an engine instance (44100 Hz sample rate)
const engine = await createEngine(44100);

Building a Patch

Add Modules

// Add modules by name and type
engine.add_module('vco', 'Vco');
engine.add_module('vca', 'Vca');
engine.add_module('output', 'StereoOutput');

Connect Modules

// Connect output port 0 of 'vco' to input port 0 of 'vca'
engine.connect('vco', 0, 'vca', 0);

// Connect VCA to stereo output (both channels)
engine.connect('vca', 0, 'output', 0);  // Left
engine.connect('vca', 0, 'output', 1);  // Right

Compile the Graph

After adding or connecting modules, compile the graph:

engine.compile();

Processing Audio

Sample-by-Sample

// Process one sample, returns [left, right]
const [left, right] = engine.tick();

More efficient for real-time audio:

// Process 128 samples at once
const samples = engine.process_block(128);
// Returns Float64Array: [L0, R0, L1, R1, ...]

AudioWorklet Setup

For real-time audio output in the browser:

import { createQuiverAudioNode } from '@quiver/wasm';

async function startAudio(engine) {
  const audioContext = new AudioContext();

  // Create worklet node connected to engine
  const quiverNode = await createQuiverAudioNode(audioContext, engine);

  // Connect to speakers
  quiverNode.connect(audioContext.destination);

  // Start (requires user gesture)
  await audioContext.resume();

  return { audioContext, quiverNode };
}

Architecture

Main Thread                      Audio Thread
┌─────────────┐                 ┌─────────────────┐
│  React UI   │ ──postMessage──▶│  AudioWorklet   │
│  (params)   │ ◀──────────────│  (process)      │──▶ Speakers
└─────────────┘                 └─────────────────┘

React Integration

useQuiverEngine

Initialize the engine in a component:

import { useQuiverEngine } from '@quiver/react';

function Synth() {
  const { engine, loading, error } = useQuiverEngine(44100);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return <PatchEditor engine={engine} />;
}

useQuiverParam

Bind a parameter to UI:

import { useQuiverParam } from '@quiver/react';

function FrequencyKnob({ engine, nodeId }) {
  const [value, setValue, info] = useQuiverParam(engine, nodeId, 0);

  return (
    <Knob
      value={value}
      min={info.min}
      max={info.max}
      onChange={setValue}
    />
  );
}

useQuiverLevel

Display level meters:

import { useQuiverLevel } from '@quiver/react';

function Meter({ engine, nodeId, portId }) {
  const { rms_db, peak_db } = useQuiverLevel(engine, nodeId, portId);

  return <LevelMeter rms={rms_db} peak={peak_db} />;
}

Next Steps

Module Catalog

The module catalog provides a searchable registry of all available modules with metadata for building dynamic UIs.

Browsing Modules

Get All Modules

const catalog = engine.catalog();

// Returns array of CatalogEntry:
// {
//   type_id: "Vco",
//   name: "VCO",
//   category: "oscillator",
//   description: "Voltage-controlled oscillator with multiple waveforms",
//   keywords: ["oscillator", "vco", "saw", "square", "triangle"],
//   inputs: [{ id: 0, name: "v_oct", kind: "VoltPerOctave" }, ...],
//   outputs: [{ id: 0, name: "out", kind: "Audio" }],
//   params: [{ id: "0", name: "frequency", ... }]
// }

Filter by Category

const oscillators = engine.by_category('oscillator');
const filters = engine.by_category('filter');
const utilities = engine.by_category('utility');

Categories

CategoryDescriptionExamples
oscillatorSound sourcesVco, Lfo, NoiseGenerator
envelopeTime-based modulationAdsrEnvelope, SlewLimiter
filterFrequency shapingSvfFilter, DiodeLadderFilter
amplifierLevel controlVca, Mixer, Attenuverter
effectAudio effectsSaturator, Wavefolder, RingModulator
utilityCV/signal utilitiesQuantizer, SampleAndHold, Clock
ioInput/outputExternalInput, StereoOutput

Searching Modules

Full-text search with relevance scoring:

const results = engine.search('filter');

// Returns matches sorted by relevance:
// [
//   { entry: {...}, score: 100 },  // Exact type_id match
//   { entry: {...}, score: 80 },   // Name contains "filter"
//   { entry: {...}, score: 50 },   // Keyword match
// ]

Search Matching

Match TypeScoreExample
Exact type_id100“Vco” matches Vco
Name contains80-90“osc” matches “VCO”
Description60“waveform” matches VCO description
Keyword40-50“analog” matches tagged modules
Category10“filter” matches filter category

Module Metadata

Each catalog entry provides rich metadata for UI generation:

Port Information

const entry = catalog.find(m => m.type_id === 'SvfFilter');

// Input ports
entry.inputs.forEach(port => {
  console.log(port.id, port.name, port.kind);
  // 0, "input", "Audio"
  // 1, "cutoff", "CvBipolar"
  // 2, "resonance", "CvUnipolar"
});

// Output ports
entry.outputs.forEach(port => {
  console.log(port.id, port.name, port.kind);
  // 0, "lowpass", "Audio"
  // 1, "highpass", "Audio"
  // 2, "bandpass", "Audio"
});

Parameter Information

entry.params.forEach(param => {
  console.log(param);
  // {
  //   id: "0",
  //   name: "cutoff",
  //   min: 20.0,
  //   max: 20000.0,
  //   default: 1000.0,
  //   curve: "exponential",
  //   control_type: "knob",
  //   format: { type: "frequency" }
  // }
});

Building a Module Browser UI

function ModuleBrowser({ engine, onSelect }) {
  const [query, setQuery] = useState('');
  const [category, setCategory] = useState(null);

  const modules = useMemo(() => {
    if (query) {
      return engine.search(query).map(r => r.entry);
    }
    if (category) {
      return engine.by_category(category);
    }
    return engine.catalog();
  }, [query, category]);

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search modules..."
      />

      <select onChange={e => setCategory(e.target.value || null)}>
        <option value="">All Categories</option>
        <option value="oscillator">Oscillators</option>
        <option value="filter">Filters</option>
        <option value="envelope">Envelopes</option>
        {/* ... */}
      </select>

      <ul>
        {modules.map(m => (
          <li key={m.type_id} onClick={() => onSelect(m.type_id)}>
            <strong>{m.name}</strong>
            <span>{m.category}</span>
            <p>{m.description}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

Signal Type Colors

For cable visualization, use the standard signal colors:

const colors = engine.signal_colors();
// {
//   audio: "#ff6b6b",      // Red
//   cv_bipolar: "#4ecdc4", // Cyan
//   cv_unipolar: "#95e879",// Green
//   volt_per_octave: "#ffd93d", // Yellow
//   gate: "#c44dff",       // Purple
//   trigger: "#ff6bcb",    // Magenta
//   clock: "#ffa94d"       // Orange
// }

Port Compatibility

Check if two ports can be connected:

const compat = engine.check_compatibility(
  'vco', 0,    // source: VCO output 0
  'vcf', 0     // dest: VCF input 0
);

// Returns: "exact" | "allowed" | "warning" | "incompatible"
ResultMeaningUI Hint
exactSame signal typeGreen cable
allowedCompatible typesNormal cable
warningMay clip or mismatchYellow cable
incompatibleCannot connectPrevent connection

Observable Streaming

Quiver provides real-time data streams for building responsive visualizations like level meters, oscilloscopes, and spectrum analyzers.

Observable Types

TypeDescriptionData
ParamParameter value changes{ value: f64 }
LevelAudio level metering{ rms_db: f64, peak_db: f64 }
GateBinary on/off state{ active: bool }
ScopeWaveform samples{ samples: f32[] }
SpectrumFFT magnitude{ bins: f32[], freq_range: [f32, f32] }

Subscribing to Updates

Subscribe

engine.subscribe([
  // Level meter on output module, port 0
  { type: 'level', node_id: 'output', port_id: 0 },

  // Oscilloscope on VCO output
  { type: 'scope', node_id: 'vco', port_id: 0, buffer_size: 512 },

  // Gate state on LFO square output
  { type: 'gate', node_id: 'lfo', port_id: 1 },

  // Spectrum analyzer
  { type: 'spectrum', node_id: 'output', port_id: 0, fft_size: 256 },

  // Parameter tracking
  { type: 'param', node_id: 'vco', param_id: '0' }
]);

Unsubscribe

// Unsubscribe by ID
engine.unsubscribe([
  'level:output:0',
  'scope:vco:0'
]);

// Clear all subscriptions
engine.clear_subscriptions();

Polling Updates

Call poll_updates() in your render loop to receive accumulated updates:

function animate() {
  // Get all pending updates since last poll
  const updates = engine.poll_updates();

  for (const update of updates) {
    switch (update.type) {
      case 'param':
        handleParamUpdate(update.node_id, update.param_id, update.value);
        break;

      case 'level':
        handleLevelUpdate(update.node_id, update.port_id,
                          update.rms_db, update.peak_db);
        break;

      case 'gate':
        handleGateUpdate(update.node_id, update.port_id, update.active);
        break;

      case 'scope':
        handleScopeUpdate(update.node_id, update.port_id, update.samples);
        break;

      case 'spectrum':
        handleSpectrumUpdate(update.node_id, update.port_id,
                             update.bins, update.freq_range);
        break;
    }
  }

  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

Update Deduplication

The observer automatically deduplicates updates:

  • Only the latest value for each subscription is kept
  • At most 1000 pending updates are buffered
  • Oldest updates are dropped if buffer overflows

This ensures the UI always shows current state without flooding.

Level Metering

Level updates provide RMS and peak measurements in decibels:

// Subscribe to level
engine.subscribe([
  { type: 'level', node_id: 'output', port_id: 0 }
]);

// Handle updates
function handleLevelUpdate(nodeId, portId, rmsDb, peakDb) {
  // rmsDb: Root-mean-square level (-inf to 0 dB)
  // peakDb: Peak level (-inf to 0 dB)

  // Map to meter height (0-100%)
  const rmsHeight = Math.max(0, (rmsDb + 60) / 60 * 100);
  const peakHeight = Math.max(0, (peakDb + 60) / 60 * 100);

  meterElement.style.setProperty('--rms', `${rmsHeight}%`);
  meterElement.style.setProperty('--peak', `${peakHeight}%`);
}

Level Meter Configuration

The observer uses a 128-sample buffer by default (~3ms at 44.1kHz), providing smooth metering at 60Hz update rate.

Gate Detection

Gate updates fire on state changes with hysteresis:

  • On threshold: > 2.5V
  • Off threshold: < 0.5V
engine.subscribe([
  { type: 'gate', node_id: 'lfo', port_id: 1 }
]);

function handleGateUpdate(nodeId, portId, active) {
  ledElement.classList.toggle('active', active);
}

Oscilloscope Display

Scope updates provide a buffer of waveform samples:

engine.subscribe([
  { type: 'scope', node_id: 'vco', port_id: 0, buffer_size: 512 }
]);

function handleScopeUpdate(nodeId, portId, samples) {
  const canvas = scopeCanvas;
  const ctx = canvas.getContext('2d');
  const width = canvas.width;
  const height = canvas.height;

  ctx.clearRect(0, 0, width, height);
  ctx.beginPath();

  for (let i = 0; i < samples.length; i++) {
    const x = (i / samples.length) * width;
    const y = (1 - (samples[i] + 1) / 2) * height;

    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }

  ctx.stroke();
}

Buffer Size

Choose buffer size based on your needs:

SizeDuration @ 44.1kHzUse Case
1282.9msFast updates, percussion
2565.8msGeneral purpose
51211.6msSmooth waveforms
102423.2msLow-frequency LFOs

Spectrum Analyzer

Spectrum updates provide FFT magnitude bins in dB:

engine.subscribe([
  { type: 'spectrum', node_id: 'output', port_id: 0, fft_size: 256 }
]);

function handleSpectrumUpdate(nodeId, portId, bins, freqRange) {
  // bins: magnitude in dB for each frequency bin (-100 to 0)
  // freqRange: [minHz, maxHz] (e.g., [0, 22050])

  const canvas = spectrumCanvas;
  const ctx = canvas.getContext('2d');
  const width = canvas.width;
  const height = canvas.height;
  const binWidth = width / bins.length;

  ctx.clearRect(0, 0, width, height);

  for (let i = 0; i < bins.length; i++) {
    // Map dB to height (clamped to -60dB floor)
    const db = Math.max(-60, bins[i]);
    const barHeight = ((db + 60) / 60) * height;

    ctx.fillRect(
      i * binWidth,
      height - barHeight,
      binWidth - 1,
      barHeight
    );
  }
}

FFT Configuration

FFT SizeBinsFreq Resolution @ 44.1kHz
12864344 Hz
256128172 Hz
51225686 Hz
102451243 Hz

The DFT uses a Hann window to reduce spectral leakage.

React Hooks

The @quiver/react package provides hooks for common patterns:

import {
  useQuiverLevel,
  useQuiverScope,
  useQuiverGate,
  useQuiverSpectrum
} from '@quiver/react';

function OutputMeter({ engine }) {
  const { rms_db, peak_db } = useQuiverLevel(engine, 'output', 0);
  return <Meter rms={rms_db} peak={peak_db} />;
}

function VcoScope({ engine }) {
  const { samples } = useQuiverScope(engine, 'vco', 0, 512);
  return <Oscilloscope samples={samples} />;
}

function LfoLed({ engine }) {
  const { active } = useQuiverGate(engine, 'lfo', 1);
  return <Led on={active} />;
}

Performance Tips

  1. Subscribe only to what you display - Unused subscriptions waste CPU
  2. Use appropriate buffer sizes - Larger = less CPU, slower updates
  3. Throttle UI updates - 60fps is usually sufficient
  4. Batch DOM updates - Use requestAnimationFrame grouping
  5. Consider Web Workers - Offload FFT visualization to worker

The Three-Layer Architecture

Quiver’s architecture bridges the gap between mathematical rigor and practical flexibility through three distinct layers.

graph TB
    subgraph "Layer 3: Patch Graph"
        L3A[Dynamic Topology]
        L3B[Runtime Patching]
        L3C[Type-Erased Interface]
    end

    subgraph "Layer 2: Port System"
        L2A[Signal Conventions]
        L2B[Port Definitions]
        L2C[Hardware Semantics]
    end

    subgraph "Layer 1: Typed Combinators"
        L1A[Arrow Composition]
        L1B[Compile-Time Types]
        L1C[Zero-Cost Abstractions]
    end

    L1A --> L2A
    L1B --> L2B
    L1C --> L2C
    L2A --> L3A
    L2B --> L3B
    L2C --> L3C

    style L1A fill:#4a9eff,color:#fff
    style L1B fill:#4a9eff,color:#fff
    style L1C fill:#4a9eff,color:#fff
    style L2A fill:#f9a826,color:#000
    style L2B fill:#f9a826,color:#000
    style L2C fill:#f9a826,color:#000
    style L3A fill:#50c878,color:#fff
    style L3B fill:#50c878,color:#fff
    style L3C fill:#50c878,color:#fff

Layer 1: Typed Combinators

The foundational layer provides Arrow-style functional composition with full compile-time type checking.

The Module Trait

pub trait Module: Send {
    type In;   // Input signal type
    type Out;  // Output signal type

    fn tick(&mut self, input: Self::In) -> Self::Out;
    fn process(&mut self, input: &[Self::In], output: &mut [Self::Out]);
    fn reset(&mut self);
    fn set_sample_rate(&mut self, sample_rate: f64);
}

The associated types In and Out enable compile-time verification that modules connect correctly.

Combinators

The ModuleExt trait provides composition operations:

CombinatorSignaturePurpose
chainA → B then B → C = A → CSequential composition
parallel(A → B) *** (C → D) = (A,C) → (B,D)Parallel processing
fanoutA → B and A → C = A → (B,C)Split input
first(A → B) on (A, X) = (B, X)Process first element
feedbackLoop with unit delayRecursion

Type Safety Example

// This compiles: types match
let synth = vco.chain(vcf).chain(vca);
// Vco: () → f64
// Svf: f64 → f64
// Vca: f64 → f64
// Result: () → f64 ✓

// This won't compile: type mismatch
let bad = vco.chain(stereo_module);
// Vco: () → f64
// StereoModule: (f64, f64) → (f64, f64)
// Error: expected f64, found (f64, f64) ✗

Layer 2: Port System

The middle layer adds hardware semantics through signal types and port definitions.

Signal Kinds

pub enum SignalKind {
    Audio,           // ±5V AC-coupled audio
    CvBipolar,       // ±5V control voltage
    CvUnipolar,      // 0-10V control voltage
    VoltPerOctave,   // 1V/Oct pitch standard
    Gate,            // 0V or +5V sustained
    Trigger,         // 0V or +5V brief pulse
    Clock,           // Regular timing pulses
}

Port Definitions

let spec = PortSpec::new()
    .with_input("in", PortDef::audio())
    .with_input("cutoff", PortDef::cv_unipolar().with_default(5.0))
    .with_output("lp", PortDef::audio())
    .with_output("hp", PortDef::audio());

The GraphModule Trait

Bridges typed modules to the graph:

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);
}

Layer 3: Patch Graph

The top layer provides runtime-configurable topology for maximum flexibility.

The Patch Container

pub struct Patch {
    nodes: SlotMap<NodeId, Box<dyn GraphModule>>,
    cables: Vec<Cable>,
    output_node: Option<NodeId>,
    processing_order: Vec<NodeId>,
}

Key Operations

// Add modules
let vco = patch.add("vco", Vco::new(44100.0));

// Connect ports
patch.connect(vco.out("saw"), vcf.in_("in"))?;

// Compile for processing
patch.compile()?;

// Process audio
let (left, right) = patch.tick();

Graph Processing

Compilation performs:

  1. Topological sort (Kahn’s algorithm)
  2. Cycle detection (no feedback without explicit delay)
  3. Signal validation (type checking with configurable strictness)

Layer Interaction

flowchart LR
    subgraph "Development Time"
        A[Define Module<br/>with types]
    end

    subgraph "Build Time"
        B[Implement<br/>GraphModule]
    end

    subgraph "Runtime"
        C[Add to Patch]
        D[Connect Ports]
        E[Compile & Run]
    end

    A --> B --> C --> D --> E

Example: Full Stack

// Layer 1: Typed module with compile-time checking
struct MyOsc {
    phase: f64,
    freq: f64,
}

impl Module for MyOsc {
    type In = f64;   // Frequency input
    type Out = f64;  // Audio output

    fn tick(&mut self, freq: f64) -> f64 {
        self.freq = freq;
        self.phase += freq / 44100.0;
        (self.phase * 2.0 * PI).sin() * 5.0
    }
}

// Layer 2: Port specification for graph integration
impl GraphModule for MyOsc {
    fn port_spec(&self) -> PortSpec {
        PortSpec::new()
            .with_input("freq", PortDef::cv_unipolar())
            .with_output("out", PortDef::audio())
    }

    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
        let freq = inputs.get("freq") * 20.0 + 20.0;  // 20-220 Hz
        let sample = <Self as Module>::tick(self, freq);
        outputs.set("out", sample);
    }
}

// Layer 3: Runtime patching
let osc = patch.add("osc", MyOsc { phase: 0.0, freq: 0.0 });
patch.connect(lfo.out("out"), osc.in_("freq"))?;

When to Use Each Layer

LayerUse When
Layer 1Building DSP algorithms with type safety
Layer 2Defining module interfaces for reuse
Layer 3Creating user-patchable synthesizers

The layers compose naturally—you can write a tight, typed DSP core and expose it through the graph system for flexible routing.

Category Theory and Quivers

The name “Quiver” isn’t arbitrary—it comes from category theory, where a quiver is a directed graph that forms the foundation for understanding morphisms and composition.

What is a Quiver?

In mathematics, a quiver is a directed graph consisting of:

  • A set of vertices (objects)
  • A set of arrows (morphisms) between vertices
graph LR
    A((A)) -->|f| B((B))
    B -->|g| C((C))
    A -->|h| C
    B -->|i| B

Sound familiar? This is exactly a modular synthesizer:

  • Vertices = Modules
  • Arrows = Patch cables

Category Theory Basics

A category consists of:

  1. Objects: Things we’re studying (modules)
  2. Morphisms: Transformations between objects (signal flow)
  3. Composition: Combining morphisms (chaining modules)
  4. Identity: Do-nothing morphism for each object

The Laws

For any morphisms $f: A \to B$, $g: B \to C$, $h: C \to D$:

Identity: $$\text{id}_B \circ f = f = f \circ \text{id}_A$$

Associativity: $$(h \circ g) \circ f = h \circ (g \circ f)$$

In Quiver’s Terms

Category TheoryQuiver Audio
ObjectsSignal types (f64, (f64, f64))
MorphismsModules (Vco, Svf, Vca)
Compositionchain combinator
IdentityIdentity module

The Identity Law

// Identity does nothing
let id = Identity::<f64>::new();

// f >>> id = f
let same1 = vco.chain(id);

// id >>> f = f
let same2 = id.chain(vco);

The Associativity Law

// These produce identical behavior:
let way1 = (vco.chain(vcf)).chain(vca);
let way2 = vco.chain(vcf.chain(vca));

// Grouping doesn't matter—only the order

Arrows: Richer Structure

Quiver uses Arrow semantics, an extension of categories that adds:

First and Second

Apply a morphism to part of a pair:

// first: (A → B) → ((A, X) → (B, X))
let process_left = filter.first();  // Filter left channel only

// second: (A → B) → ((X, A) → (X, B))
let process_right = filter.second();  // Filter right channel only

Parallel Composition (⊗)

Process two signals independently:

$$f \otimes g : (A, C) \to (B, D)$$

// Process stereo with different filters
let stereo = left_filter.parallel(right_filter);
// (f64, f64) → (f64, f64)

Fanout (Δ)

Duplicate input to multiple processors:

$$\Delta_f^g : A \to (B, C)$$

// Send same input to two effects
let split = reverb.fanout(delay);
// f64 → (f64, f64)

The Arrow Laws

For arrows $f$, $g$, $h$:

Composition with first: $$\text{first}(f \ggg g) = \text{first}(f) \ggg \text{first}(g)$$

Identity with first: $$\text{first}(\text{id}) = \text{id}$$

Commutativity: $$\text{first}(f) \ggg (\text{id} \times g) = (\text{id} \times g) \ggg \text{first}(f)$$

These laws ensure that complex compositions behave predictably.

Why This Matters

1. Predictable Behavior

The laws guarantee that refactoring preserves behavior:

// These are equivalent by associativity
let v1 = a.chain(b.chain(c));
let v2 = a.chain(b).chain(c);
// Safe to refactor between them

2. Type-Driven Design

Types prevent invalid connections:

// Type error: can't chain mono into stereo
let bad = mono_module.chain(stereo_module);
//        ^^^^^^^^^^^ f64
//                    ^^^^^^^^^^^^^^^^^ (f64, f64)

3. Compositionality

Build complex systems from simple parts:

// Each piece is simple
let voice = vco.chain(vcf).chain(vca);
let effects = delay.chain(reverb);
let mixer = voice.fanout(effects).chain(mix);

// Composition creates complexity

Quivers vs Categories

A quiver is “pre-categorical”—it has the structure but not necessarily composition:

graph LR
    subgraph "Quiver (Graph)"
        Q1((1)) -->|a| Q2((2))
        Q2 -->|b| Q3((3))
    end

    subgraph "Category (+ Composition)"
        C1((1)) -->|a| C2((2))
        C2 -->|b| C3((3))
        C1 -.->|b∘a| C3
    end

Quiver (the library) sits at this boundary:

  • Layer 3 is quiver-like: arbitrary graph structure
  • Layer 1 is category-like: composition is built-in

The Free Category

Given a quiver, the free category adds all possible compositions. This is exactly what compile() does—it computes the transitive closure of signal flow.

// Define edges (quiver)
patch.connect(a, b);
patch.connect(b, c);

// compile() computes the free category
patch.compile()?;
// Now signal flows: a → b → c

Further Reading

  • Categories for the Working Mathematician — Saunders Mac Lane
  • Category Theory for Programmers — Bartosz Milewski
  • Seven Sketches in Compositionality — Fong & Spivak

Arrow Combinators

Quiver’s Layer 1 provides Arrow-style combinators for composing DSP modules with compile-time type safety.

The Core Abstraction

Every module is a function from input to output:

$$M : \text{In} \to \text{Out}$$

Combinators let us build complex modules from simple ones without losing type safety.

Chain (Sequential Composition)

The most fundamental combinator: output of first feeds input of second.

flowchart LR
    IN[Input] --> A[Module A]
    A --> B[Module B]
    B --> OUT[Output]

$$\text{chain}(f, g) = g \circ f : A \to C$$

let synth = vco.chain(vcf).chain(vca);
// () → f64 → f64 → f64
// Types flow through automatically

Parallel (Independent Processing)

Process two signals independently:

flowchart LR
    subgraph Input
        I1[A]
        I2[C]
    end
    subgraph Processing
        M1[Module F]
        M2[Module G]
    end
    subgraph Output
        O1[B]
        O2[D]
    end

    I1 --> M1 --> O1
    I2 --> M2 --> O2

$$(f \parallel g)(a, c) = (f(a), g(c))$$

let stereo = left_channel.parallel(right_channel);
// (f64, f64) → (f64, f64)

Fanout (Split and Process)

Send input to multiple processors:

flowchart LR
    IN[Input A] --> SPLIT((•))
    SPLIT --> F[Module F]
    SPLIT --> G[Module G]
    F --> O1[B]
    G --> O2[C]

$$\text{fanout}(f, g)(a) = (f(a), g(a))$$

let effects = signal.fanout(reverb, delay);
// f64 → (f64, f64)

First and Second

Apply a module to only one part of a pair:

flowchart LR
    subgraph "first(F)"
        I1[A] --> F[F]
        I2[X] --> P[Pass]
        F --> O1[B]
        P --> O2[X]
    end

$$\text{first}(f)(a, x) = (f(a), x)$$

// Process only the left channel
let left_only = filter.first();
// (f64, f64) → (f64, f64)

Feedback (With Delay)

Create a feedback loop with unit delay:

flowchart LR
    IN[Input] --> SUM((+))
    SUM --> PROC[Process]
    PROC --> OUT[Output]
    PROC --> DEL[z⁻¹]
    DEL --> SUM

$$y[n] = f(x[n] + y[n-1])$$

let echo = delay.feedback(0.5);  // 50% feedback

Map and Contramap

Transform signals without creating new modules:

// Map: transform output
let boosted = vco.map(|x| x * 2.0);

// Contramap: transform input
let scaled = vca.contramap(|x| x * 0.5);
flowchart LR
    subgraph "map(f, g)"
        IN[A] --> M[Module]
        M --> TRANS[g]
        TRANS --> OUT[C]
    end

Identity

The do-nothing module—but type-safe:

let id = Identity::<f64>::new();
// f64 → f64, output equals input

// Useful for type alignment
let aligned = mono.parallel(Identity::new());

Constant

Always produce the same output:

let dc = Constant::new(5.0);
// () → f64, always 5.0

// Useful for fixed CV values
let offset = Constant::new(2.5).chain(adder.second());

Split and Merge

Work with tuples:

// Split: duplicate input
let dup = Split::<f64>::new();
// f64 → (f64, f64)

// Merge: combine with function
let summer = Merge::new(|a, b| a + b);
// (f64, f64) → f64

Swap

Swap tuple elements:

let swapped = Swap::<f64, f64>::new();
// (A, B) → (B, A)

Combining Combinators

Build complex signal flow:

// Classic synth voice with stereo chorus
let voice = vco
    .chain(vcf)
    .chain(vca)
    .chain(Split::new())  // Mono to stereo
    .chain(
        chorus_left.parallel(chorus_right)
    )
    .chain(
        Merge::new(|l, r| (l * 0.5, r * 0.5))
    );

Type Inference

Rust’s type inference works through combinators:

// Types are inferred
let synth = vco.chain(vcf).chain(vca);
// Compiler knows: () → f64

// Explicit types when needed
let stereo: Chain<VCO, Parallel<VCF, VCF>> = ...;

Zero-Cost Abstraction

Combinators compile to efficient code:

// This combinator chain...
let synth = vco.chain(vcf).chain(vca);

// ...compiles to essentially:
fn tick(&mut self) -> f64 {
    self.vca.tick(
        self.vcf.tick(
            self.vco.tick(())
        )
    )
}

No heap allocation, no virtual dispatch, no runtime overhead.

Pattern: Effect Rack

fn effect_rack(
    effects: Vec<Box<dyn Module<In=f64, Out=f64>>>
) -> impl Module<In=f64, Out=f64>
{
    effects.into_iter()
        .fold(Identity::new(), |acc, fx| acc.chain(fx))
}

Pattern: Parallel Voices

fn parallel_voices<V: Module<In=f64, Out=f64>>(
    voices: [V; 4]
) -> impl Module<In=(f64, f64, f64, f64), Out=(f64, f64, f64, f64)>
{
    let [v1, v2, v3, v4] = voices;
    v1.parallel(v2).parallel(v3).parallel(v4)
}

Signal Conventions

Quiver adopts hardware modular synthesizer conventions for signal levels and types. Understanding these is essential for creating patches that behave predictably.

Voltage Standards

The library models signals on the Eurorack standard:

graph TB
    subgraph "Signal Types"
        AUDIO["Audio<br/>±5V peak"]
        CVBI["CV Bipolar<br/>±5V"]
        CVUNI["CV Unipolar<br/>0-10V"]
        VOCT["V/Oct<br/>±10V"]
        GATE["Gate<br/>0V / +5V"]
        TRIG["Trigger<br/>0V / +5V pulse"]
    end

    style AUDIO fill:#4a9eff,color:#fff
    style CVBI fill:#f9a826,color:#000
    style CVUNI fill:#f9a826,color:#000
    style VOCT fill:#e74c3c,color:#fff
    style GATE fill:#50c878,color:#fff
    style TRIG fill:#50c878,color:#fff

Audio Signals

Audio oscillates symmetrically around zero:

$$\text{audio}(t) \in [-5V, +5V]$$

  • Nominal level: ±5V peak
  • AC-coupled: No DC offset
  • Bandwidth: 20Hz - 20kHz
// VCO outputs are ±5V audio
let saw = vco.out("saw");  // -5V to +5V

Clipping

Signals exceeding ±5V may clip at later stages:

graph LR
    INPUT[+8V<br/>signal] --> CLIP[Clipping<br/>Stage]
    CLIP --> OUTPUT[+5V<br/>clipped]

Some modules add soft saturation to avoid harsh clipping.

Control Voltage (CV)

Unipolar CV (0-10V)

For parameters that don’t make sense negative:

ParameterExample Values
Filter cutoff0V = 20Hz, 10V = 20kHz
LFO rate0V = 0.01Hz, 10V = 30Hz
Envelope times0V = 1ms, 10V = 10s
PortDef::cv_unipolar().with_default(5.0)

Bipolar CV (±5V)

For parameters that can go both ways:

ParameterExample Values
Pan-5V = left, +5V = right
Pitch bend±5V = ±semitones
FM depth±5V = direction
PortDef::cv_bipolar().with_default(0.0)

Volt-per-Octave (V/Oct)

The pitch standard: 1 volt = 1 octave

$$f = f_0 \cdot 2^{V}$$

Where $f_0 = 261.63\text{Hz}$ (C4) at 0V.

Reference Table

VoltageNoteMIDIFrequency
-3VC12432.70 Hz
-2VC23665.41 Hz
-1VC348130.81 Hz
0VC460261.63 Hz
+1VC572523.25 Hz
+2VC6841046.50 Hz
+3VC7962093.00 Hz

Semitones and Cents

$$\text{semitone} = \frac{1}{12}V \approx 83.33\text{mV}$$ $$\text{cent} = \frac{1}{1200}V \approx 0.833\text{mV}$$

// MIDI note to V/Oct
fn midi_to_voct(note: u8) -> f64 {
    (note as f64 - 60.0) / 12.0
}

// V/Oct to frequency
fn voct_to_freq(v: f64) -> f64 {
    261.63 * 2.0_f64.powf(v)
}

Gates and Triggers

Gate Signal

Sustained high while key is held:

     ┌──────────────┐
+5V  │              │
     │              │
 0V ─┘              └───
     Key Down    Key Up
  • High: +5V (or >2.5V threshold)
  • Low: 0V
  • Duration: As long as key held

Trigger Signal

Brief pulse to start an event:

     ┌┐
+5V  ││
     ││
 0V ─┘└────────────────
     1-10ms pulse
  • Duration: 1-10ms typically
  • Use: Clock pulses, envelope retriggers

Clock Signals

Regular timing pulses:

 ┌─┐   ┌─┐   ┌─┐   ┌─┐
 │ │   │ │   │ │   │ │
─┘ └───┘ └───┘ └───┘ └─
  │         │
  └── 1 beat ──┘

Quiver’s Clock module provides divisions:

OutputDivision
div_1Whole notes
div_2Half notes
div_4Quarter notes
div_8Eighth notes
div_16Sixteenth notes

Signal Compatibility

The SignalKind enum helps validate connections:

pub enum SignalKind {
    Audio,         // ±5V audio
    CvBipolar,     // ±5V CV
    CvUnipolar,    // 0-10V CV
    VoltPerOctave, // Pitch
    Gate,          // 0/+5V sustained
    Trigger,       // 0/+5V pulse
    Clock,         // Timing
}

Compatibility Matrix

From ↓ / To →AudioCV BiCV UniV/OctGate
Audio
CV Bipolar
CV Unipolar
V/Oct
Gate

✓ = Compatible, ⚠ = May work, ✗ = Likely error

Input Summing

Multiple sources to one input are mixed:

flowchart LR
    LFO1[LFO 1<br/>+2V] --> SUM((Σ))
    LFO2[LFO 2<br/>+1V] --> SUM
    OFFSET[Offset<br/>+3V] --> SUM
    SUM -->|+6V| DEST[Destination]

This models hardware behavior where CVs sum at input jacks.

Normalled Connections

Some inputs have default sources when unpatched:

// StereoOutput normalizes right to left
PortDef::audio().with_normalled_to("left")

If nothing patched to “right”, it receives the “left” signal.

Analog Modeling

Quiver includes tools to model the imperfections and character of analog hardware. These subtle variations are what make vintage synthesizers sound “alive.”

Why Model Analog?

Digital audio is mathematically perfect. Analog audio has:

  • Component tolerance: Resistors/capacitors vary ±1-5%
  • Thermal drift: Parameters change with temperature
  • Nonlinearities: Saturation, clipping, distortion
  • Noise: Thermal noise, power supply hum

These “imperfections” create the warmth and character we love.

Saturation Functions

Quiver provides several saturation models:

Hyperbolic Tangent

Smooth, tube-like warmth:

$$y = \tanh(x \cdot \text{drive})$$

use quiver::analog::saturation;

let output = saturation::tanh_sat(input, drive);
graph LR
    subgraph "tanh Saturation"
        A["Linear<br/>region"] --> B["Soft<br/>compression"]
        B --> C["Limiting"]
    end

Soft Clipping

Adjustable knee:

$$y = \begin{cases} x & |x| < k \ \text{sign}(x) \cdot (k + (1-k) \cdot \tanh(\frac{|x|-k}{1-k})) & |x| \geq k \end{cases}$$

let output = saturation::soft_clip(input, knee);

Asymmetric Saturation

Even harmonics from asymmetry (like tubes):

$$y = \tanh(a \cdot x^+) - \tanh(b \cdot x^-)$$

let output = saturation::asym_sat(input, pos_drive, neg_drive);

Diode Clipping

Hard edges like guitar pedals:

$$y = \begin{cases} \text{threshold} & x > \text{threshold} \ x & |x| \leq \text{threshold} \ -\text{threshold} & x < -\text{threshold} \end{cases}$$

Wave Folding

Complex harmonics through folding:

graph TB
    subgraph "Wavefolding"
        IN[Input Signal] --> FOLD[Fold Function]
        FOLD --> OUT[Rich Harmonics]
    end

$$y = \sin(\text{folds} \cdot \pi \cdot x)$$

Component Modeling

ComponentModel

Simulates real component variation:

use quiver::analog::ComponentModel;

// 1% tolerance like precision resistors
let model = ComponentModel::resistor_1_percent();

// 5% tolerance like standard capacitors
let model = ComponentModel::capacitor_5_percent();

// Apply to a value
let actual_value = model.apply(nominal_value);

Each instance gets a random offset within tolerance, creating unique “units.”

Example: Filter Cutoff Variation

// Two filters with component variation
let filter1 = DiodeLadderFilter::new(44100.0)
    .with_component_model(ComponentModel::capacitor_5_percent());
let filter2 = DiodeLadderFilter::new(44100.0)
    .with_component_model(ComponentModel::capacitor_5_percent());

// filter1 and filter2 will have slightly different cutoffs
// even with identical CV input

Thermal Modeling

ThermalModel

Temperature affects component values:

use quiver::analog::ThermalModel;

let thermal = ThermalModel::new()
    .with_temp_coefficient(0.002)  // 0.2% per °C
    .with_time_constant(30.0);     // 30 second thermal lag

// In processing loop
let temp_factor = thermal.update(ambient_temp, dt);
let adjusted_value = base_value * temp_factor;

This creates slow drift that adds organic movement.

V/Oct Tracking Errors

Real oscillators don’t track pitch perfectly:

use quiver::analog::VoctTrackingModel;

let tracking = VoctTrackingModel::new()
    .with_tracking_error(0.01)      // 1% scale error
    .with_offset_error(0.005);      // 5mV offset

let actual_voct = tracking.apply(intended_voct);

This is why analog synths need tuning!

High Frequency Rolloff

Real circuits have bandwidth limits:

use quiver::analog::HighFrequencyRolloff;

let rolloff = HighFrequencyRolloff::new(44100.0)
    .with_cutoff(15000.0)  // -3dB at 15kHz
    .with_order(2);        // 12dB/octave

let filtered = rolloff.process(sample);

The AnalogVco Module

Combines all effects:

use quiver::analog::AnalogVco;

let vco = AnalogVco::new(44100.0)
    .with_tracking(VoctTrackingModel::default())
    .with_rolloff(HighFrequencyRolloff::default())
    .with_components(ComponentModel::resistor_1_percent())
    .with_saturation(|x| saturation::tanh_sat(x, 1.5));
flowchart LR
    VOCT[V/Oct In] --> TRACK[Tracking<br/>Errors]
    TRACK --> OSC[Oscillator<br/>Core]
    OSC --> SAT[Saturation]
    SAT --> ROLL[HF Rolloff]
    ROLL --> OUT[Output]

    COMP[Component<br/>Model] -.-> OSC
    TEMP[Thermal<br/>Model] -.-> OSC

Crosstalk

Signals bleeding between channels:

use quiver::modules::Crosstalk;

let crosstalk = Crosstalk::new()
    .with_amount(0.01);  // 1% bleed

// Left and right influence each other slightly

Ground Loop Hum

Power supply noise:

use quiver::modules::GroundLoop;

let hum = GroundLoop::new(44100.0)
    .with_frequency(60.0)   // 60Hz (US) or 50Hz (EU)
    .with_amplitude(0.01)   // Very subtle
    .with_harmonics(3);     // Include some harmonics

When to Use Analog Modeling

EffectUse Case
SaturationWarmth, harmonics, preventing clipping
Component toleranceUnique character per voice
Thermal driftSlow organic movement
V/Oct errorsVintage oscillator feel
HF rolloffSoften digital harshness
CrosstalkSubtle stereo interaction
Ground loopVintage authenticity

Performance Considerations

  • Saturation: Cheap (just math)
  • Component models: Cheap (multiply)
  • Thermal: Very cheap (slow update)
  • Rolloff: Medium (filter)
  • Full AnalogVco: Sum of above

Use sparingly for character; most processing should be “clean” digital.

Block Processing & SIMD

Real-time audio demands efficiency. Quiver provides tools for high-performance processing.

The Challenge

Audio processing must:

  1. Complete within the buffer deadline
  2. Have bounded, predictable latency
  3. Never block on locks or allocation

At 44.1kHz with 128-sample buffers, you have ~2.9ms per callback.

Block Processing

Instead of sample-by-sample, process in blocks:

flowchart LR
    subgraph "Sample-by-Sample"
        S1[Tick] --> S2[Tick] --> S3[Tick] --> S4[...]
    end

    subgraph "Block Processing"
        B1[Process<br/>Block] --> B2[Process<br/>Block]
    end

Benefits

AspectSample-by-SampleBlock
Function call overheadPer samplePer block
Cache efficiencyPoorGood
SIMD opportunityNoneFull
Branch predictionFrequentRare

AudioBlock

Quiver’s block container:

use quiver::prelude::*;

const BLOCK_SIZE: usize = 64;  // Typical size

let mut block = AudioBlock::new();

// Fill with samples
for i in 0..BLOCK_SIZE {
    block[i] = generate_sample(i);
}

// Process entire block
filter.process_block(&mut block);

StereoBlock

For stereo processing:

let mut stereo = StereoBlock::new();

// Set channels
stereo.set_left(&left_samples);
stereo.set_right(&right_samples);

// Pan operation
stereo.pan(0.3);  // 30% right

// Mix to mono
let mono = stereo.mix(0.5, 0.5);

SIMD Vectorization

SIMD (Single Instruction Multiple Data) processes 4-8 samples simultaneously:

flowchart LR
    subgraph "Scalar"
        A1[a₁] --> OP1[×]
        B1[b₁] --> OP1
        OP1 --> R1[c₁]
    end

    subgraph "SIMD (4-wide)"
        A2["[a₁ a₂ a₃ a₄]"] --> OP2[×]
        B2["[b₁ b₂ b₃ b₄]"] --> OP2
        OP2 --> R2["[c₁ c₂ c₃ c₄]"]
    end

Enabling SIMD

# Cargo.toml
[dependencies]
quiver = { version = "0.1", features = ["simd"] }

SIMD Operations

use quiver::simd::*;

let mut block = AudioBlock::new();

// SIMD-accelerated operations
block.add_scalar(offset);     // Add constant
block.mul_scalar(gain);       // Multiply by constant
block.add_block(&other);      // Add another block
block.mul_block(&envelope);   // Multiply by envelope

// These use SSE/AVX when available

Alignment

SIMD requires aligned memory:

// AudioBlock is automatically aligned
let block = AudioBlock::new();  // 16-byte aligned

// Manual alignment for custom types
#[repr(align(16))]
struct MyBuffer([f64; 64]);

Lazy Evaluation

Defer computation until needed:

use quiver::simd::{LazySignal, LazyBlock};

// Create lazy signal
let lazy = LazySignal::new(|| expensive_computation());

// Value computed only when needed
let value = lazy.evaluate();

// Lazy block operations
let lazy_block = LazyBlock::new()
    .add_scalar(1.0)
    .mul_scalar(0.5)
    .add_block(&other);

// All operations fused when materialized
let result = lazy_block.materialize();

Fusion Benefits

// Without fusion: 3 loops
for s in block { s += 1.0; }
for s in block { s *= 0.5; }
for s in block { s += other[i]; }

// With fusion: 1 loop
for i in 0..len {
    block[i] = (block[i] + 1.0) * 0.5 + other[i];
}

Ring Buffers

Efficient delay lines:

use quiver::simd::RingBuffer;

let mut delay = RingBuffer::new(44100);  // 1 second

// Write sample, get delayed sample
let delayed = delay.tick(input);

// Access specific delay
let tapped = delay.read(11025);  // 0.25 second delay
flowchart LR
    IN[Input] --> WRITE[Write<br/>Head]
    WRITE --> BUF[Circular<br/>Buffer]
    BUF --> READ[Read<br/>Head]
    READ --> OUT[Output]

    WRITE -.->|wrap| WRITE
    READ -.->|wrap| READ

ProcessContext

Bundle processing state:

let ctx = ProcessContext {
    sample_rate: 44100.0,
    block_size: 64,
    transport_position: 0,
    is_playing: true,
};

module.process_with_context(&mut block, &ctx);

Best Practices

1. Preallocate Everything

// Do this once at startup
let mut block = AudioBlock::new();
let mut delay = RingBuffer::new(max_delay);

// Not in the audio callback
let block = AudioBlock::new();  // ❌ Allocation!

2. Avoid Branching in Inner Loops

// Bad: branch per sample
for i in 0..len {
    if condition {
        block[i] = process_a(block[i]);
    } else {
        block[i] = process_b(block[i]);
    }
}

// Good: branch once per block
if condition {
    for i in 0..len { block[i] = process_a(block[i]); }
} else {
    for i in 0..len { block[i] = process_b(block[i]); }
}

3. Use Block Operations

// Bad: call per sample
for i in 0..len {
    block[i] = vco.tick();
}

// Good: block processing
vco.process(&[], &mut block);

4. Profile Regularly

use std::time::Instant;

let start = Instant::now();
process_block(&mut block);
let duration = start.elapsed();

if duration.as_secs_f64() * 1000.0 > 2.9 {
    eprintln!("Warning: approaching deadline!");
}

Memory Layout

Cache-Friendly Access

// Good: sequential access
for i in 0..len {
    output[i] = input[i] * gain;
}

// Bad: strided access
for i in (0..len).step_by(4) {
    output[i] = input[i] * gain;
}

Structure of Arrays

For multiple parallel signals:

// Array of Structures (cache unfriendly)
struct Voice { phase: f64, freq: f64, amp: f64 }
let voices: [Voice; 8];

// Structure of Arrays (cache friendly)
struct Voices {
    phases: [f64; 8],
    freqs: [f64; 8],
    amps: [f64; 8],
}

Real-Time Latency Constraints

Real-time audio processing requires strict timing guarantees. This guide explains latency budgets, how to calculate them, and strategies for meeting real-time constraints.

The Fundamental Constraint

Audio hardware delivers samples in fixed-size buffers at regular intervals. Your processing must complete before the next buffer arrives, or you’ll hear clicks, pops, or dropouts.

sequenceDiagram
    participant HW as Audio Hardware
    participant CPU as Your Code

    HW->>CPU: Buffer N arrives
    Note over CPU: Process buffer
    CPU->>HW: Buffer N complete
    HW->>CPU: Buffer N+1 arrives
    Note over CPU: Must finish before<br/>next buffer!

Time Budget Calculation

The time budget is determined by:

time_budget = buffer_size / sample_rate

Common Configurations

Sample RateBuffer 64Buffer 128Buffer 256Buffer 512
44.1 kHz1.45 ms2.90 ms5.80 ms11.61 ms
48 kHz1.33 ms2.67 ms5.33 ms10.67 ms
96 kHz0.67 ms1.33 ms2.67 ms5.33 ms
192 kHz0.33 ms0.67 ms1.33 ms2.67 ms

Key insight: Higher sample rates with smaller buffers give tighter deadlines. At 96 kHz with 128 samples, you have only 1.33 ms.

Ultra-Low Latency

For live performance or software instruments:

Buffer SizeTime @ 48 kHzUse Case
16 samples0.33 msHardware-like response
32 samples0.67 msProfessional monitoring
48 samples1.00 msLive performance
64 samples1.33 msStudio tracking

These tight budgets require careful optimization.

Round-Trip Latency

Total perceived latency includes:

total_latency = input_buffer + processing + output_buffer

With double-buffering (common in audio drivers):

round_trip = 2 × buffer_time = 2 × (buffer_size / sample_rate)
BufferRound-Trip @ 48 kHz
642.67 ms
1285.33 ms
25610.67 ms
51221.33 ms

Musicians typically notice latency above 10-15 ms.

Polyphony and Latency

Processing time scales with voice count. Quiver benchmarks show:

graph LR
    subgraph "Voice Scaling"
        V1[1 Voice] --> V4[4 Voices]
        V4 --> V8[8 Voices]
        V8 --> V16[16 Voices]
        V16 --> V32[32 Voices]
    end

Polyphony Guidelines

VoicesRecommended BufferNotes
1-464-128 samplesLow latency possible
8-16128-256 samplesTypical synthesizer
32+256-512 samplesHigh polyphony, larger buffer

With unison enabled, each voice costs more:

// 8 voices × 4 unison = 32 effective oscillators
poly.set_unison(UnisonConfig::new(4, 15.0));

Meeting Real-Time Constraints

1. Preallocate Everything

Never allocate memory in the audio callback:

// At initialization (OK)
let mut patch = Patch::new(sample_rate);
let mut buffer = AudioBlock::new(256);
patch.compile().unwrap();

// In audio callback
fn process(&mut self, output: &mut [f32]) {
    // ❌ NEVER allocate here
    // let data = vec![0.0; 256];

    // ✓ Use preallocated structures
    for sample in output.iter_mut() {
        *sample = self.patch.tick().0 as f32;
    }
}

2. Compile Patches Once

Topological sorting happens at compile time:

// At startup
patch.compile().unwrap();  // O(V + E) graph sort

// In audio callback
patch.tick();  // O(V) processing only

3. Avoid Blocking Operations

Never perform these in audio callbacks:

OperationAlternative
File I/OPreload samples
NetworkUse separate thread
Mutex locksUse lock-free atomics
Memory allocationPreallocate buffers
Console outputLog to ring buffer

4. Use Block Processing

Process samples in blocks for better cache efficiency:

// Less efficient: sample-by-sample
for _ in 0..buffer_size {
    output = patch.tick();
}

// More efficient: leverage SIMD
let mut block = AudioBlock::new(buffer_size);
// Process full block with vectorized operations
block.mul_scalar(0.5);

See Block Processing & SIMD for details.

5. Profile Your Patches

Measure actual processing time:

use std::time::Instant;

let start = Instant::now();
for _ in 0..buffer_size {
    patch.tick();
}
let duration = start.elapsed();

let budget_ns = (buffer_size as f64 / sample_rate) * 1e9;
let usage_percent = (duration.as_nanos() as f64 / budget_ns) * 100.0;

eprintln!("CPU usage: {:.1}%", usage_percent);

Keep usage below 70% for headroom.

Module Costs

Not all modules are equal. Relative costs from benchmarks:

ModuleRelative CostNotes
VCABaseline
LFOSimple oscillator
ADSREnvelope
VCOMultiple waveforms
SVFState-variable filter
DiodeLadderNonlinear modeling
WavefolderSaturation math

Complex patches scale accordingly:

Patch TypeTypical ModulesRelative Cost
SimpleVCO → VCF → VCA~6×
Modulated+ LFO, ADSR~8×
Complex2×VCO, Ladder, effects~15×

Configuration Recommendations

Live Performance

Priority: Minimal latency

let sample_rate = 48000.0;
let buffer_size = 64;  // 1.33 ms

// Limit polyphony
let poly = PolyPatch::new(8, sample_rate);

// Use efficient filter
let vcf = Svf::new(sample_rate);  // Not DiodeLadder

Studio Production

Priority: Balance latency and features

let sample_rate = 48000.0;
let buffer_size = 256;  // 5.33 ms

// More headroom for complex patches
let poly = PolyPatch::new(16, sample_rate);

// Can use heavier processing
let vcf = DiodeLadderFilter::new(sample_rate);

Offline Rendering

Priority: Quality over latency

let sample_rate = 96000.0;
let buffer_size = 1024;  // Non-realtime

// Maximum polyphony
let poly = PolyPatch::new(64, sample_rate);

// Full analog modeling
let vco = AnalogVco::new(sample_rate);

Measuring with Benchmarks

Run Quiver’s benchmark suite to validate your system:

cargo bench --bench audio_performance

Key benchmarks:

  • realtime_compliance: Tests common pro-audio configs
  • buffer_processing: Per-buffer-size timing
  • polyphony/voice_scaling: Voice count impact
  • stress/ultra_low_latency: 16-48 sample buffers

Example output interpretation:

realtime_compliance/complex_patch/48kHz/256
    time: [423.1 µs 425.8 µs 428.9 µs]

Budget at 48 kHz / 256 samples = 5333 µs. Using 426 µs = 8% CPU.

Troubleshooting

Audio Dropouts

  1. Increase buffer size - Try doubling it
  2. Reduce polyphony - Fewer voices = faster
  3. Simplify patches - Remove expensive modules
  4. Check background processes - CPU spikes cause glitches
  5. Profile the patch - Find the bottleneck

High CPU Usage

  1. Compile the patch - Ensure patch.compile() was called
  2. Use SVF over DiodeLadder - 40% cheaper
  3. Reduce unison - Each adds full voice cost
  4. Lower sample rate - 44.1 kHz vs 96 kHz
  5. Enable SIMD - features = ["simd"]

Inconsistent Timing

  1. Disable CPU scaling - Set performance governor
  2. Isolate audio thread - Pin to dedicated core
  3. Increase thread priority - Real-time scheduling
  4. Check thermal throttling - Cool your CPU

Summary

ScenarioBufferLatencyMax Voices
Live instrument641.33 ms4-8
Studio tracking1282.67 ms8-16
Mixing2565.33 ms16-32
Mastering512+10+ msUnlimited

The key principles:

  1. Know your budget: buffer_size / sample_rate
  2. Preallocate everything: No allocations in callbacks
  3. Profile regularly: Measure, don’t guess
  4. Leave headroom: Target 70% CPU max
  5. Trade-offs exist: Latency vs. polyphony vs. complexity

Oscillators

Oscillators are the sound sources in any synthesizer—they generate the raw waveforms that filters and effects shape.

VCO (Voltage-Controlled Oscillator)

The primary sound source for subtractive synthesis.

let vco = patch.add("vco", Vco::new(44100.0));

Inputs

PortSignalRangeDescription
voctV/Oct±10VPitch (0V = C4)
fmBipolar CV±5VFrequency modulation
pwUnipolar CV0-10VPulse width (5V = 50%)
syncGate0/5VHard sync reset

Outputs

PortSignalDescription
sinAudioSine wave
triAudioTriangle wave
sawAudioSawtooth wave
sqrAudioSquare/pulse wave

Waveform Mathematics

Sine: $$y(t) = A \sin(2\pi f t)$$

Sawtooth (BLIT): $$y(t) = 2 \left( \frac{t}{T} - \lfloor \frac{t}{T} + 0.5 \rfloor \right)$$

Triangle: $$y(t) = 2 \left| 2 \left( \frac{t}{T} - \lfloor \frac{t}{T} + 0.5 \rfloor \right) \right| - 1$$

Square/Pulse: $$y(t) = \text{sign}(\sin(2\pi f t) - \cos(\pi \cdot \text{PW}))$$

Usage Example

// Basic VCO with external pitch
patch.connect(pitch_cv.out("out"), vco.in_("voct"))?;

// FM synthesis
patch.connect(modulator.out("sin"), vco.in_("fm"))?;

// PWM (pulse width modulation)
patch.connect(lfo.out("tri"), vco.in_("pw"))?;

LFO (Low-Frequency Oscillator)

Sub-audio oscillator for modulation.

let lfo = patch.add("lfo", Lfo::new(44100.0));

Inputs

PortSignalRangeDescription
rateUnipolar CV0-10VFrequency (0.01-30 Hz)
depthUnipolar CV0-10VOutput amplitude
resetTrigger0/5VPhase reset

Outputs

PortSignalDescription
sinBipolar CVSine wave (±5V)
triBipolar CVTriangle wave
sawBipolar CVSawtooth wave
sqrBipolar CVSquare wave
sin_uniUnipolar CVUnipolar sine (0-10V)

Rate Mapping

Default rate curve: $$f = 0.01 \cdot e^{(\text{CV}/10) \cdot \ln(3000)}$$

CVFrequency
0V0.01 Hz
5V~1 Hz
10V30 Hz

Noise Generator

White and pink noise sources.

let noise = patch.add("noise", NoiseGenerator::new());

Outputs

PortSignalDescription
white_leftAudioWhite noise (left)
white_rightAudioWhite noise (right)
pink_leftAudioPink noise (left)
pink_rightAudioPink noise (right)

Noise Spectra

White noise: Equal energy per frequency (flat spectrum)

$$S(f) = \text{constant}$$

Pink noise: Equal energy per octave (-3dB/octave)

$$S(f) \propto \frac{1}{f}$$

Pink noise is generated using the Voss-McCartney algorithm.


AnalogVco

VCO with analog imperfections for authentic vintage sound.

use quiver::analog::AnalogVco;

let vco = patch.add("vco", AnalogVco::new(44100.0));

Additional Features

  • V/Oct tracking errors
  • Component tolerance variation
  • High-frequency rolloff
  • Soft saturation

See Analog Modeling for details.


Common Patterns

Detuned Oscillators

let vco1 = patch.add("vco1", Vco::new(sr));
let vco2 = patch.add("vco2", Vco::new(sr));

// Slight detune for thickness
let detune = patch.add("detune", Offset::new(0.01));  // ~12 cents

patch.connect(pitch.out("out"), vco1.in_("voct"))?;
patch.connect(pitch.out("out"), vco2.in_("voct"))?;
patch.connect(detune.out("out"), vco2.in_("voct"))?;  // Adds to pitch

Hard Sync

// Slave syncs to master
patch.connect(master.out("sqr"), slave.in_("sync"))?;

// Modulate slave pitch for classic sync sweep
patch.connect(lfo.out("sin"), slave.in_("voct"))?;

FM Synthesis

// Carrier:Modulator = 1:1 for harmonic FM
patch.connect(modulator.out("sin"), carrier.in_("fm"))?;

// Control FM depth with envelope
patch.connect(env.out("env"), fm_vca.in_("cv"))?;
patch.connect(fm_vca.out("out"), carrier.in_("fm"))?;

Filters

Filters shape the harmonic content of sound by attenuating certain frequencies while passing others.

SVF (State-Variable Filter)

A versatile 12dB/octave filter with multiple simultaneous outputs.

let vcf = patch.add("vcf", Svf::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
cutoffUnipolar CV0-10VCutoff frequency
resonanceUnipolar CV0-10VResonance/Q (0-1)
fmBipolar CV±5VFrequency modulation
trackingV/Oct±10VKeyboard tracking

Outputs

PortSignalDescription
lpAudioLowpass (removes highs)
bpAudioBandpass (passes band)
hpAudioHighpass (removes lows)
notchAudioNotch (removes band)

Transfer Functions

Lowpass: $$H_{LP}(s) = \frac{\omega_c^2}{s^2 + \frac{\omega_c}{Q}s + \omega_c^2}$$

Highpass: $$H_{HP}(s) = \frac{s^2}{s^2 + \frac{\omega_c}{Q}s + \omega_c^2}$$

Bandpass: $$H_{BP}(s) = \frac{\frac{\omega_c}{Q}s}{s^2 + \frac{\omega_c}{Q}s + \omega_c^2}$$

Cutoff Mapping

CVFrequency
0V20 Hz
5V~630 Hz
10V20,000 Hz

Resonance Behavior

ResonanceCharacter
0.0Flat response
0.5Slight peak
0.9Prominent peak
0.95+Self-oscillation

At high resonance, the filter produces a sine wave at the cutoff frequency.


DiodeLadderFilter

Classic 24dB/octave ladder filter with diode saturation modeling.

let ladder = patch.add("filter", DiodeLadderFilter::new(44100.0));

Inputs

PortSignalDescription
inAudioAudio input
cutoffUnipolar CVCutoff frequency
resonanceUnipolar CVResonance (0-1)
driveUnipolar CVSaturation amount

Output

PortSignalDescription
outAudioFiltered output

Characteristics

  • 24dB/octave slope (4-pole)
  • Diode saturation per stage
  • Warm, slightly dirty character
  • Resonance with bass loss (like original Moog)

The Ladder Topology

flowchart LR
    IN[Input] --> S1[Stage 1<br/>-6dB/oct]
    S1 --> S2[Stage 2<br/>-6dB/oct]
    S2 --> S3[Stage 3<br/>-6dB/oct]
    S3 --> S4[Stage 4<br/>-6dB/oct]
    S4 --> OUT[Output<br/>-24dB/oct]
    S4 -->|Resonance| IN

Filter Modulation Techniques

Envelope → Filter

Classic brightness sweep:

patch.connect(env.out("env"), vcf.in_("cutoff"))?;
// Fast decay = plucky, slow decay = pad

LFO → Filter

Rhythmic movement:

patch.connect(lfo.out("sin"), vcf.in_("fm"))?;

Keyboard Tracking

Higher notes = higher cutoff:

patch.connect(pitch.out("out"), vcf.in_("tracking"))?;
// 100% tracking: cutoff follows pitch

Audio-Rate FM

Metallic/vocal effects:

// Use oscillator as modulation source
patch.connect(vco2.out("sin"), vcf.in_("fm"))?;

Response Curves

dB
 0 ├──────────────┐
   │               ╲
-6 ├                ╲
   │                 ╲ LP
-12├                  ╲
   │                   ╲
-24├                    ╲
   └────────────────────────
           fc          Frequency

Common Settings

SoundCutoffResonanceNotes
Warm bassLowLowFull body
Acid squelchSweptHighTB-303 style
Vocal formantMidHighVowel-like
Bright leadHighMediumCutting
UnderwaterVery lowLowMuffled

Envelopes & Modulators

Modulation sources shape how parameters change over time, creating movement and expression.

ADSR Envelope

The classic Attack-Decay-Sustain-Release envelope generator.

let env = patch.add("env", Adsr::new(44100.0));

Inputs

PortSignalRangeDescription
gateGate0/5VTrigger/sustain signal
attackUnipolar CV0-10VAttack time (ms-s)
decayUnipolar CV0-10VDecay time (ms-s)
sustainUnipolar CV0-10VSustain level (0-100%)
releaseUnipolar CV0-10VRelease time (ms-s)

Output

PortSignalDescription
envUnipolar CVEnvelope output (0-5V)

Envelope Stages

Level
  5V ┤    ╱╲
     │   ╱  ╲____
     │  ╱        ╲
     │ ╱          ╲
  0V ┼╱────────────╲────
     A    D   S    R

Timing Curves

All stages use exponential curves:

Attack: $$v(t) = 5 \cdot (1 - e^{-t/\tau_a})$$

Decay/Release: $$v(t) = (v_{start} - v_{end}) \cdot e^{-t/\tau} + v_{end}$$

Typical Settings

SoundAttackDecaySustainRelease
Pluck5ms200ms0%100ms
Pad1s500ms80%2s
Brass50ms100ms70%200ms
Perc1ms50ms0%50ms

LFO (Low-Frequency Oscillator)

See Oscillators for full documentation.

Quick reference:

let lfo = patch.add("lfo", Lfo::new(44100.0));
patch.connect(lfo.out("sin"), vcf.in_("fm"))?;

Sample and Hold

Captures input value on trigger pulse.

let sh = patch.add("sh", SampleAndHold::new());

Inputs

PortSignalDescription
inCV/AudioSignal to sample
triggerTriggerWhen to sample

Output

PortSignalDescription
outCVHeld value

Classic Use: Random CV

// Random stepped modulation
patch.connect(noise.out("white_left"), sh.in_("in"))?;
patch.connect(clock.out("div_8"), sh.in_("trigger"))?;
patch.connect(sh.out("out"), vcf.in_("cutoff"))?;

Slew Limiter

Limits rate of change—creates portamento and smoothing.

let slew = patch.add("slew", SlewLimiter::new(44100.0));

Inputs

PortSignalDescription
inCVInput signal
riseUnipolar CVRise time (upward slew)
fallUnipolar CVFall time (downward slew)

Output

PortSignalDescription
outCVSlewed output

Applications

flowchart LR
    subgraph "Portamento"
        P1[Pitch CV] --> SLEW1[Slew] --> VCO1[VCO]
    end

    subgraph "Envelope Follower"
        P2[Audio] --> RECT[Rectify] --> SLEW2[Slew]
    end

    subgraph "Smooth Random"
        P3[S&H] --> SLEW3[Slew] --> MOD[Smooth CV]
    end

Quantizer

Snaps continuous CV to scale degrees.

let quant = patch.add("quant", Quantizer::new());

Inputs

PortSignalDescription
inV/OctUnquantized pitch
scaleCVScale selection

Output

PortSignalDescription
outV/OctQuantized pitch

Available Scales

ScaleNotes
ChromaticAll 12 semitones
Major1 2 3 4 5 6 7
Minor1 2 ♭3 4 5 ♭6 ♭7
Pentatonic1 2 3 5 6
Blues1 ♭3 4 ♭5 5 ♭7

Clock

Master timing generator.

let clock = patch.add("clock", Clock::new(44100.0));

Inputs

PortSignalDescription
tempoUnipolar CVBPM (0-10V = 20-300 BPM)
resetTriggerReset to beat 1

Outputs

PortSignalDescription
div_1TriggerWhole notes
div_2TriggerHalf notes
div_4TriggerQuarter notes
div_8TriggerEighth notes
div_16TriggerSixteenth notes
div_32Trigger32nd notes

Step Sequencer

8-step CV/gate sequencer.

let seq = patch.add("seq", StepSequencer::new());

Inputs

PortSignalDescription
clockTriggerAdvance to next step
resetTriggerReturn to step 1

Outputs

PortSignalDescription
cvV/OctStep CV value
gateGateStep gate state

Programming Steps

The sequencer holds 8 CV/gate pairs. In a full application, you’d set these via UI or MIDI.

Utilities

Utility modules for signal routing, mixing, and manipulation.

Mixer

4-channel audio mixer.

let mixer = patch.add("mixer", Mixer::new());

Inputs

PortSignalDescription
in_1 - in_4AudioAudio inputs
gain_1 - gain_4Unipolar CVChannel gains
masterUnipolar CVMaster gain

Output

PortSignalDescription
outAudioMixed output

VCA (Voltage-Controlled Amplifier)

Controls signal amplitude with CV.

let vca = patch.add("vca", Vca::new());

Inputs

PortSignalDescription
inAudioAudio input
cvUnipolar CVGain control (0-10V = 0-100%)

Output

PortSignalDescription
outAudioAmplitude-controlled output

Response

Linear response: $$\text{out} = \text{in} \times \frac{\text{cv}}{10}$$


Attenuverter

Attenuates, inverts, or amplifies signals.

let atten = patch.add("atten", Attenuverter::new());

Inputs

PortSignalDescription
inAnyInput signal
amountBipolar CVScale factor (-2 to +2)

Output

PortSignalDescription
outAnyScaled output

Amount Values

AmountEffect
-2.0Inverted and doubled
-1.0Inverted
0.0Silent
0.5Half level
1.0Unity (unchanged)
2.0Doubled

Offset

Adds DC offset (constant voltage source).

let offset = patch.add("offset", Offset::new(5.0));  // 5V

Output

PortSignalDescription
outCVConstant voltage

Common Uses

// Center LFO modulation
patch.connect(offset.out("out"), vcf.in_("cutoff"))?;  // Base cutoff
patch.connect(lfo.out("sin"), vcf.in_("fm"))?;         // Modulation

Multiple

Signal splitter (1 input to 4 outputs).

let mult = patch.add("mult", Multiple::new());

Input

PortSignalDescription
inAnyInput signal

Outputs

PortSignalDescription
out_1 - out_4AnyIdentical copies

UnitDelay

Single-sample delay (z⁻¹).

let delay = patch.add("delay", UnitDelay::new());

Input/Output

PortSignalDescription
inAnyInput
outAnyDelayed by 1 sample

Essential for feedback loops.


Crossfader

Crossfade between two signals with equal-power curve.

let xfade = patch.add("xfade", Crossfader::new());

Inputs

PortSignalDescription
aAudioFirst signal
bAudioSecond signal
mixUnipolar CVCrossfade position
panBipolar CVStereo position

Outputs

PortSignalDescription
leftAudioLeft output
rightAudioRight output

Equal Power Curve

$$\text{gain}_A = \cos\left(\frac{\pi}{2} \cdot \text{mix}\right)$$ $$\text{gain}_B = \sin\left(\frac{\pi}{2} \cdot \text{mix}\right)$$


Precision Adder

High-precision CV addition for V/Oct signals.

let adder = patch.add("adder", PrecisionAdder::new());

Inputs

PortSignalDescription
aV/OctFirst pitch
bV/OctSecond pitch (offset)

Output

PortSignalDescription
outV/OctSum of pitches

Use for transpose, octave shifts, and pitch offsets.


StereoOutput

Final stereo output stage.

let output = patch.add("output", StereoOutput::new());
patch.set_output(output.id());

Inputs

PortSignalDescription
leftAudioLeft channel
rightAudioRight channel (normalled to left)

Behavior

If only left is patched, right mirrors it (mono).


ExternalInput

Injects external CV/audio into the patch.

use std::sync::Arc;
let cv = Arc::new(AtomicF64::new(0.0));
let input = patch.add("pitch", ExternalInput::voct(Arc::clone(&cv)));

Factory Methods

MethodSignal Type
::voct()V/Oct pitch
::gate()Gate signal
::trigger()Trigger
::cv()Unipolar CV
::cv_bipolar()Bipolar CV

Output

PortSignalDescription
outVariesExternal value

Common Patterns

Voltage Processing Chain

// LFO → Attenuverter → Offset → Target
// Allows precise control of modulation depth and center
patch.connect(lfo.out("sin"), atten.in_("in"))?;
patch.connect(atten.out("out"), adder.in_("a"))?;
patch.connect(offset.out("out"), adder.in_("b"))?;
patch.connect(adder.out("out"), vcf.in_("cutoff"))?;

Parallel Signal Path

// Split signal to dry and wet paths
patch.connect(input, mult.in_("in"))?;
patch.connect(mult.out("out_1"), dry_path)?;
patch.connect(mult.out("out_2"), wet_path)?;
patch.connect(dry_path, xfade.in_("a"))?;
patch.connect(wet_path, xfade.in_("b"))?;

Logic & CV Processing

Modules for gate logic, CV comparison, and signal routing.

Logic Gates

LogicAnd

Outputs HIGH only when both inputs are HIGH.

let and_gate = patch.add("and", LogicAnd::new());
InputsOutput
0V, 0V0V
0V, 5V0V
5V, 0V0V
5V, 5V5V

LogicOr

Outputs HIGH when either input is HIGH.

let or_gate = patch.add("or", LogicOr::new());
InputsOutput
0V, 0V0V
0V, 5V5V
5V, 0V5V
5V, 5V5V

LogicXor

Outputs HIGH when exactly one input is HIGH.

let xor_gate = patch.add("xor", LogicXor::new());
InputsOutput
0V, 0V0V
0V, 5V5V
5V, 0V5V
5V, 5V0V

LogicNot

Inverts the input.

let not_gate = patch.add("not", LogicNot::new());
InputOutput
0V5V
5V0V

Comparators

Comparator

Compares two voltages.

let cmp = patch.add("cmp", Comparator::new());

Inputs

PortSignalDescription
aCVFirst signal
bCVSecond signal

Outputs

PortSignalDescription
gtGateHIGH if A > B
ltGateHIGH if A < B
eqGateHIGH if A ≈ B (within threshold)

Use Cases

// Trigger envelope when LFO rises above threshold
patch.connect(lfo.out("sin"), cmp.in_("a"))?;
patch.connect(threshold.out("out"), cmp.in_("b"))?;
patch.connect(cmp.out("gt"), env.in_("gate"))?;

Min/Max

Min

Outputs the lower of two signals.

let min = patch.add("min", Min::new());

$$\text{out} = \min(a, b)$$

Max

Outputs the higher of two signals.

let max = patch.add("max", Max::new());

$$\text{out} = \max(a, b)$$

Use Case: Limiting

// Limit modulation depth
patch.connect(lfo.out("sin"), min.in_("a"))?;
patch.connect(limit.out("out"), min.in_("b"))?;  // Maximum value

Rectifiers

Rectifier

Converts bipolar signals to various forms.

let rect = patch.add("rect", Rectifier::new());

Outputs

PortDescriptionFormula
fullFull-wave rectified$
half_posPositive half only$\max(x, 0)$
half_negNegative half only$\min(x, 0)$
absAbsolute value$
Input:      ╱╲  ╱╲
           ╱  ╲╱  ╲
Full:      ╱╲╱╲╱╲╱╲

Half+:     ╱╲  ╱╲
           ──╲╱──╲╱

Half-:       ╲╱  ╲╱
           ──  ──

Audio Applications

  • Octave doubling (full-wave rectify audio)
  • Envelope following (rectify + lowpass)
  • Distortion effects

Signal Routing

VcSwitch

Voltage-controlled signal router.

let switch = patch.add("switch", VcSwitch::new());

Inputs

PortSignalDescription
aAnyFirst signal
bAnySecond signal
selectGateWhich to output

Output

PortSignalDescription
outAnySelected signal

When select < 2.5V: output A When select >= 2.5V: output B


BernoulliGate

Probabilistic gate router.

let bernoulli = patch.add("bernoulli", BernoulliGate::new());

Inputs

PortSignalDescription
triggerTriggerInput trigger
probabilityUnipolar CVChance of A (0-100%)

Outputs

PortSignalDescription
aTriggerProbabilistic output A
bTriggerProbabilistic output B

When trigger arrives:

  • With probability P: fires A
  • With probability 1-P: fires B

Use Case: Random Variations

// 70% chance of normal note, 30% chance of accent
patch.connect(clock.out("div_8"), bernoulli.in_("trigger"))?;
patch.connect(prob_cv.out("out"), bernoulli.in_("probability"))?;
patch.connect(bernoulli.out("a"), normal_env.in_("gate"))?;
patch.connect(bernoulli.out("b"), accent_env.in_("gate"))?;

Ring Modulator

Four-quadrant multiplier for metallic sounds.

let ring = patch.add("ring", RingModulator::new());

Inputs

PortSignalDescription
carrierAudioCarrier signal
modulatorAudioModulator signal

Output

PortSignalDescription
outAudioProduct (ring mod)

Mathematics

$$\text{out} = \text{carrier} \times \text{modulator}$$

Creates sum and difference frequencies: $$\cos(f_1 t) \cdot \cos(f_2 t) = \frac{1}{2}[\cos((f_1-f_2)t) + \cos((f_1+f_2)t)]$$

Sound Character

  • Bell-like tones with related frequencies
  • Metallic, robotic sounds with unrelated frequencies
  • Classic AM radio sound

Effects

Signal processing effects for shaping sound character.

Saturator

Soft clipping distortion based on analog saturation curves.

use quiver::analog::{Saturator, saturation};

let sat = patch.add("saturator", Saturator::new(saturation::tanh_sat));

Inputs

PortSignalDescription
inAudioInput signal
driveUnipolar CVSaturation amount

Output

PortSignalDescription
outAudioSaturated output

Saturation Types

FunctionCharacter
tanh_satSmooth, tube-like
soft_clipAdjustable knee
asym_satEven harmonics
diode_clipHard, aggressive

Wavefolder

Creates complex harmonics through wavefolding.

let folder = patch.add("folder", Wavefolder::new());

Inputs

PortSignalDescription
inAudioInput signal
foldsUnipolar CVNumber of folds

Output

PortSignalDescription
outAudioFolded output

The Folding Process

Input:   ╱╲
        ╱  ╲
       ╱    ╲

1 Fold: ╱╲╱╲
       ╱    ╲

2 Folds: ╱╲╱╲╱╲╱╲
        ╱      ╲

$$y = \sin(f \cdot \pi \cdot x)$$

Where $f$ is the fold amount.


Crosstalk

Simulates channel bleed between left and right.

let crosstalk = patch.add("xtalk", Crosstalk::new());

Inputs

PortSignalDescription
leftAudioLeft channel
rightAudioRight channel
amountUnipolar CVBleed amount (0-10%)

Outputs

PortSignalDescription
leftAudioLeft with right bleed
rightAudioRight with left bleed

The Effect

$$L_{out} = L_{in} + \text{amount} \cdot R_{in}$$ $$R_{out} = R_{in} + \text{amount} \cdot L_{in}$$

Adds subtle width and analog character.


Ground Loop

Simulates 50/60Hz power supply hum.

let hum = patch.add("hum", GroundLoop::new(44100.0));

Inputs

PortSignalDescription
amountUnipolar CVHum level

Output

PortSignalDescription
outAudioHum signal

Configuration

let hum = GroundLoop::new(44100.0)
    .with_frequency(60.0)   // 60Hz (US) or 50Hz (EU)
    .with_harmonics(3);     // Include 2nd and 3rd harmonics

Mix very subtly for vintage authenticity.


Scope

Real-time waveform visualization.

let scope = patch.add("scope", Scope::new(44100.0));

Inputs

PortSignalDescription
inAudioSignal to display
triggerGateTrigger sync

Trigger Modes

ModeDescription
FreeContinuous display
RisingEdgeSync on positive zero-cross
FallingEdgeSync on negative zero-cross
SingleOne-shot capture

Reading the Buffer

let waveform = scope.buffer();
// Vec<f64> of recent samples

Spectrum Analyzer

FFT-based frequency analysis.

let analyzer = patch.add("spectrum", SpectrumAnalyzer::new(44100.0));

Input

PortSignalDescription
inAudioSignal to analyze

Reading Data

let bins = analyzer.bins();           // Frequency bins
let mags = analyzer.magnitudes();     // dB values
let peak = analyzer.peak_frequency(); // Dominant frequency

Level Meter

RMS and peak level monitoring.

let meter = patch.add("meter", LevelMeter::new(44100.0));

Input

PortSignalDescription
inAudioSignal to meter

Reading Levels

let rms = meter.rms();       // RMS level in volts
let peak = meter.peak();     // Peak level
let rms_db = meter.rms_db(); // RMS in dB

Peak Hold

let meter = LevelMeter::new(44100.0)
    .with_peak_hold(500.0);  // 500ms hold time

Building Effect Chains

Serial Processing

// Input → Saturator → Filter → Output
patch.connect(input, sat.in_("in"))?;
patch.connect(sat.out("out"), vcf.in_("in"))?;
patch.connect(vcf.out("lp"), output)?;

Parallel Processing

// Dry/wet mix
patch.connect(input, mult.in_("in"))?;
patch.connect(mult.out("out_1"), effect.in_("in"))?;  // Wet
patch.connect(mult.out("out_2"), xfade.in_("a"))?;    // Dry
patch.connect(effect.out("out"), xfade.in_("b"))?;    // Wet

Feedback Loop

// With unit delay to prevent infinite loop
patch.connect(effect.out("out"), delay.in_("in"))?;
patch.connect(delay.out("out"), atten.in_("in"))?;  // Feedback amount
patch.connect(atten.out("out"), effect.in_("in"))?;

I/O Modules

Modules for external communication, MIDI, OSC, and audio output.

StereoOutput

The final audio output stage—every patch needs one.

let output = patch.add("output", StereoOutput::new());
patch.set_output(output.id());

Inputs

PortSignalDescription
leftAudioLeft channel
rightAudioRight channel

Normalled Behavior

If only left is connected, right automatically mirrors it.

// Mono output - left copied to right
patch.connect(mono_source, output.in_("left"))?;

// Stereo output
patch.connect(left_source, output.in_("left"))?;
patch.connect(right_source, output.in_("right"))?;

Getting Output

let (left, right) = patch.tick();  // Returns (f64, f64)

ExternalInput

Injects values from external sources (MIDI, UI, etc.).

use std::sync::Arc;

let cv = Arc::new(AtomicF64::new(0.0));
let input = patch.add("cv_in", ExternalInput::new(
    Arc::clone(&cv),
    SignalKind::CvUnipolar,
));

Factory Methods

MethodSignal KindTypical Use
::voct(arc)V/OctPitch from MIDI
::gate(arc)GateNote on/off
::trigger(arc)TriggerClock pulses
::cv(arc)Unipolar CVMod wheel, expression
::cv_bipolar(arc)Bipolar CVPitch bend

Thread-Safe Updates

// From MIDI thread
cv.set(midi_cc_value / 127.0 * 10.0);

// Audio thread reads latest value
let input_module = ExternalInput::cv(Arc::clone(&cv));

MidiState

Comprehensive MIDI state tracking.

let midi = MidiState::new();

// In MIDI callback
midi.note_on(60, 100);   // Note 60, velocity 100
midi.note_off(60);
midi.control_change(1, 64);  // CC1 = 64
midi.pitch_bend(8192);       // Center

// Read current state
let voct = midi.voct();      // V/Oct of current note
let gate = midi.gate();      // Gate state (0 or 5V)
let velocity = midi.velocity();  // 0.0 - 1.0
let mod_wheel = midi.cc(1);      // CC value

OSC Integration

OscInput

Receives OSC messages as CV.

let osc_in = patch.add("cutoff_osc", OscInput::new("/synth/cutoff"));
patch.connect(osc_in.out("out"), vcf.in_("cutoff"))?;

OscReceiver

Network OSC receiver.

let receiver = OscReceiver::new("127.0.0.1:9000")?;

// In your control thread
while let Some(msg) = receiver.recv()? {
    match msg.address.as_str() {
        "/synth/cutoff" => {
            if let Some(OscValue::Float(v)) = msg.args.first() {
                cutoff_cv.set(*v as f64 * 10.0);
            }
        }
        _ => {}
    }
}

OscPattern

Pattern matching for OSC addresses.

let pattern = OscPattern::new("/synth/voice/*/cutoff");

// Matches:
// /synth/voice/1/cutoff
// /synth/voice/2/cutoff
// etc.

if pattern.matches(&msg.address) {
    // Handle message
}

OscBinding

Maps OSC to patch parameters.

let bindings = vec![
    OscBinding::new("/synth/cutoff", "vcf.cutoff", 0.0..10.0),
    OscBinding::new("/synth/resonance", "vcf.resonance", 0.0..1.0),
];

for binding in &bindings {
    if let Some(value) = binding.process(&msg) {
        patch.set_parameter(&binding.target, value);
    }
}

Web Audio

WebAudioProcessor

Process audio for Web Audio API.

let config = WebAudioConfig {
    sample_rate: 44100.0,
    channels: 2,
    buffer_size: 128,
};

let processor = WebAudioProcessor::new(patch);

WebAudioWorklet

For AudioWorklet integration.

let worklet = WebAudioWorklet::new(patch);

// In worklet process()
worklet.process(&input, &mut output);

Interleaving

Web Audio uses interleaved stereo:

// Convert from separate channels
let interleaved = interleave_stereo(&left, &right);

// Convert to separate channels
let (left, right) = deinterleave_stereo(&interleaved);

Common Patterns

MIDI-Controlled Synth

let pitch_cv = Arc::new(AtomicF64::new(0.0));
let gate_cv = Arc::new(AtomicF64::new(0.0));
let vel_cv = Arc::new(AtomicF64::new(5.0));

let pitch = patch.add("pitch", ExternalInput::voct(pitch_cv.clone()));
let gate = patch.add("gate", ExternalInput::gate(gate_cv.clone()));
let velocity = patch.add("vel", ExternalInput::cv(vel_cv.clone()));

// In MIDI handler
fn handle_note_on(note: u8, vel: u8) {
    pitch_cv.set((note as f64 - 60.0) / 12.0);
    vel_cv.set(vel as f64 / 127.0 * 10.0);
    gate_cv.set(5.0);
}

fn handle_note_off(note: u8) {
    gate_cv.set(0.0);
}

OSC-Controlled Parameters

let bindings = HashMap::from([
    ("/filter/cutoff", vcf_cutoff_cv.clone()),
    ("/filter/reso", vcf_reso_cv.clone()),
    ("/env/attack", env_attack_cv.clone()),
]);

// In OSC handler
if let Some(cv) = bindings.get(&msg.address.as_str()) {
    if let Some(OscValue::Float(v)) = msg.args.first() {
        cv.set(*v as f64);
    }
}

Signal Types Cheatsheet

Quick reference for Quiver’s signal conventions.

Signal Ranges

TypeRangeZero PointUse
Audio±5V0VSound waveforms
CV Unipolar0-10V0VCutoff, rate, depth
CV Bipolar±5V0VPan, FM, bend
V/Oct±10V0V = C4Pitch
Gate0V or 5V0VSustained on/off
Trigger0V or 5V0VBrief pulse
Clock0V or 5V0VTiming pulses

SignalKind Enum

pub enum SignalKind {
    Audio,           // ±5V AC-coupled
    CvBipolar,       // ±5V control
    CvUnipolar,      // 0-10V control
    VoltPerOctave,   // 1V/Oct pitch
    Gate,            // 0V/+5V sustained
    Trigger,         // 0V/+5V pulse
    Clock,           // Timing pulses
}

PortDef Factory Methods

MethodSignal KindDefaultAttenuverter
::audio()Audio0.0No
::cv_unipolar()CvUnipolar0.0Yes
::cv_bipolar()CvBipolar0.0Yes
::voct()VoltPerOctave0.0No
::gate()Gate0.0No
::trigger()Trigger0.0No
::clock()Clock0.0No

Compatibility Quick Reference

Audio ←→ CV:      ⚠ Works but check intent
CV ←→ V/Oct:      ⚠ Usually wrong
Gate ←→ Trigger:  ✓ Compatible
Clock ←→ Trigger: ✓ Compatible
V/Oct ←→ Audio:   ✗ Usually wrong

Common Voltage Conversions

MIDI Note to V/Oct

fn midi_to_voct(note: u8) -> f64 {
    (note as f64 - 60.0) / 12.0
}

V/Oct to Frequency

fn voct_to_hz(v: f64) -> f64 {
    261.63 * 2.0_f64.powf(v)
}

MIDI CC to CV

// 0-127 → 0-10V
fn cc_to_cv(cc: u8) -> f64 {
    cc as f64 / 127.0 * 10.0
}

// 0-127 → ±5V
fn cc_to_cv_bipolar(cc: u8) -> f64 {
    (cc as f64 / 127.0 - 0.5) * 10.0
}

MIDI Velocity to CV

fn velocity_to_cv(vel: u8) -> f64 {
    vel as f64 / 127.0 * 10.0  // 0-10V
}

Pitch Bend to V/Oct

// Standard ±2 semitones
fn bend_to_voct(bend: i16) -> f64 {
    (bend as f64 / 8192.0) * (2.0 / 12.0)
}

Attenuverter Reference

ValueEffect
-2.0Invert and double
-1.0Invert
-0.5Invert and halve
0.0Silence
0.5Half level
1.0Unity (unchanged)
2.0Double

Cable Attenuation

Cable::new()
    .with_attenuation(0.5)   // Scale signal
    .with_offset(2.0)        // Add DC offset

Input Summing

Multiple cables to one input are added:

LFO1 (+2V) ─┐
            ├── Input receives +5V
LFO2 (+3V) ─┘

Normalled Connections

When input is unpatched, uses normalled source:

PortDef::audio().with_normalled_to("other_port")

V/Oct Reference

Complete reference for the Volt-per-Octave pitch standard.

The Standard

1 Volt = 1 Octave

$$f = f_0 \cdot 2^V$$

Where:

  • $f$ = frequency in Hz
  • $f_0$ = 261.63 Hz (C4 at 0V)
  • $V$ = voltage

Complete Note Table

NoteMIDIV/OctFrequency
C012-4.000V16.35 Hz
C124-3.000V32.70 Hz
C236-2.000V65.41 Hz
C348-1.000V130.81 Hz
C4600.000V261.63 Hz
C572+1.000V523.25 Hz
C684+2.000V1046.50 Hz
C796+3.000V2093.00 Hz
C8108+4.000V4186.01 Hz

Chromatic Scale (Octave 4)

NoteMIDIV/OctFrequency
C460+0.000V261.63 Hz
C#461+0.083V277.18 Hz
D462+0.167V293.66 Hz
D#463+0.250V311.13 Hz
E464+0.333V329.63 Hz
F465+0.417V349.23 Hz
F#466+0.500V369.99 Hz
G467+0.583V392.00 Hz
G#468+0.667V415.30 Hz
A469+0.750V440.00 Hz
A#470+0.833V466.16 Hz
B471+0.917V493.88 Hz

Intervals

IntervalSemitonesVoltage
Unison00.000V
Minor 2nd10.083V
Major 2nd20.167V
Minor 3rd30.250V
Major 3rd40.333V
Perfect 4th50.417V
Tritone60.500V
Perfect 5th70.583V
Minor 6th80.667V
Major 6th90.750V
Minor 7th100.833V
Major 7th110.917V
Octave121.000V

Precise Values

Semitone

$$1 \text{ semitone} = \frac{1}{12} \text{V} = 83.33\overline{3} \text{mV}$$

Cent

$$1 \text{ cent} = \frac{1}{1200} \text{V} = 0.833\overline{3} \text{mV}$$

Conversion Functions

MIDI to V/Oct

fn midi_to_voct(note: u8) -> f64 {
    (note as f64 - 60.0) / 12.0
}

V/Oct to MIDI

fn voct_to_midi(v: f64) -> u8 {
    (v * 12.0 + 60.0).round() as u8
}

V/Oct to Frequency

fn voct_to_hz(v: f64) -> f64 {
    261.63 * 2.0_f64.powf(v)
}

Frequency to V/Oct

fn hz_to_voct(f: f64) -> f64 {
    (f / 261.63).log2()
}

Common Tuning Offsets

OffsetEffect
+1VUp one octave
-1VDown one octave
+0.583VUp a fifth
+0.333VUp a major third
+0.01V~12 cents (detune)

Tracking Errors

Real analog oscillators have tracking errors:

Error TypeTypical Amount
Scale error±1-5%
Offset error±10-50mV
Temperature drift1-5mV/°C

At high frequencies, these compound and cause tuning issues.

A440 Reference

A4 (440 Hz) = MIDI 69 = +0.750V

To tune to A=440:

  • C4 must be at 261.63 Hz (0V)
  • Ratio: 440/261.63 = 1.682

Microtonal

For non-12TET tunings:

// Pythagorean major third (81/64 instead of 5/4)
let pythagorean_third = (81.0_f64 / 64.0).log2();
// = 0.339 V instead of 0.333 V

// Just intonation fifth (3/2)
let just_fifth = 1.5_f64.log2();
// = 0.585 V instead of 0.583 V

Preset Library

Quiver includes a library of preset patches for learning and quick starts.

Using Presets

use quiver::prelude::*;

let library = PresetLibrary::new();

// List all presets
for preset in library.list() {
    println!("{}: {}", preset.name, preset.description);
}

// Get by category
let basses = library.by_category(PresetCategory::Bass);

// Search by tag
let acid = library.search_tags(&["acid"]);

// Build a preset
let patch = library.get("Moog Bass")?.build(44100.0)?;

Categories

CategoryDescription
ClassicIconic synth sounds
BassBass patches
LeadLead/solo sounds
PadSustained pad sounds
PercussionDrums and percussion
EffectEffects and textures
SoundDesignExperimental sounds
TutorialLearning examples

Classic Presets

Moog Bass

Category: Bass
Tags: moog, classic, warm

Architecture:
  VCO (saw) → Ladder Filter → VCA
  ADSR → Filter + VCA

Character:
  Deep, warm, punchy bass with filter sweep

Juno Pad

Category: Pad
Tags: juno, warm, lush

Architecture:
  VCO (saw + sub) → SVF → VCA → Chorus
  Slow ADSR → Filter + VCA

Character:
  Wide, warm pad with subtle movement

303 Acid

Category: Bass
Tags: 303, acid, squelchy

Architecture:
  VCO (saw) → Diode Ladder → VCA
  Fast ADSR → Filter (high resonance)

Character:
  Classic acid squelch with resonant filter

Sync Lead

Category: Lead
Tags: sync, aggressive, lead

Architecture:
  Master VCO → sync → Slave VCO
  LFO → Slave pitch
  SVF → VCA

Character:
  Cutting, aggressive lead with sync sweep

PWM Strings

Category: Pad
Tags: strings, pwm, ensemble

Architecture:
  VCO (pulse) → SVF → VCA
  LFO → Pulse width
  Detuned voice layering

Character:
  Lush string ensemble with movement

Tutorial Presets

Basic Subtractive (Difficulty: 1)

Purpose: Learn VCO → VCF → VCA chain

Modules:
  - VCO: Basic oscillator
  - SVF: Lowpass filter
  - VCA: Volume control

Try:
  - Change waveform (saw/sqr/tri)
  - Adjust filter cutoff
  - Add resonance

Envelope Basics (Difficulty: 1)

Purpose: Learn ADSR envelope shaping

Modules:
  - VCO → VCF → VCA
  - ADSR envelope

Try:
  - Adjust attack for slow fade-in
  - Short decay for plucky sounds
  - Sustain level for held notes
  - Release for pad tails

Filter Modulation (Difficulty: 2)

Purpose: Learn LFO → filter modulation

Modules:
  - VCO → VCF → VCA
  - LFO → filter cutoff

Try:
  - Adjust LFO rate
  - Try different LFO waveforms
  - Change modulation depth

FM Basics (Difficulty: 3)

Purpose: Intro to FM synthesis

Modules:
  - Carrier VCO
  - Modulator VCO → Carrier FM

Try:
  - Adjust C:M ratio
  - Change modulation depth
  - Envelope the FM amount

Polyphony Intro (Difficulty: 3)

Purpose: Learn voice allocation

Modules:
  - 4-voice polyphonic patch
  - VoiceAllocator

Try:
  - Play chords
  - Change allocation mode
  - Add unison/detune

Sound Design Presets

Metallic Ring

Category: SoundDesign
Tags: ring, metallic, experimental

Architecture:
  VCO1 × VCO2 (ring mod)
  Inharmonic ratio

Character:
  Bell-like metallic tones

Noise Sweep

Category: SoundDesign
Tags: noise, sweep, texture

Architecture:
  Noise → Resonant filter
  LFO → filter sweep

Character:
  Evolving filtered noise

Wavefold Growl

Category: SoundDesign
Tags: wavefold, aggressive, bass

Architecture:
  VCO → Wavefolder → Filter

Character:
  Aggressive, harmonically rich growl

Building Custom Presets

// Create preset info
let info = PresetInfo {
    name: "My Preset".to_string(),
    category: PresetCategory::Lead,
    description: "A custom lead sound".to_string(),
    tags: vec!["custom".into(), "lead".into()],
    difficulty: 2,
};

// Build the patch
fn build_preset(sample_rate: f64) -> Patch {
    let mut patch = Patch::new(sample_rate);
    // ... add modules and connections ...
    patch
}

Preset File Format

Presets can be saved as JSON:

{
  "name": "My Preset",
  "category": "Lead",
  "description": "Description here",
  "tags": ["custom", "lead"],
  "patch": {
    "modules": [...],
    "cables": [...],
    "parameters": {...}
  }
}

See Serialization for details.

Mathematical Foundations

The mathematics underlying Quiver’s design and DSP algorithms.

Category Theory

Quivers

A quiver $Q = (V, E, s, t)$ consists of:

  • $V$: Set of vertices (objects)
  • $E$: Set of edges (arrows/morphisms)
  • $s: E \to V$: Source function
  • $t: E \to V$: Target function

In Quiver:

  • Vertices = Modules
  • Edges = Patch cables
  • Source/Target = Output/Input ports

The Free Category

Given a quiver $Q$, the free category $\text{Path}(Q)$ has:

  • Objects: Same as $Q$’s vertices
  • Morphisms: Paths (sequences of composable arrows)
  • Composition: Path concatenation

This is what patch.compile() computes.

Arrow Laws

For arrows $f: A \to B$, $g: B \to C$, $h: C \to D$:

Identity: $$\text{id}_B \circ f = f \circ \text{id}_A = f$$

Associativity: $$(h \circ g) \circ f = h \circ (g \circ f)$$

First/Second: $$\text{first}(f) = f \times \text{id}$$ $$\text{second}(f) = \text{id} \times f$$

Digital Signal Processing

Sampling Theory

Nyquist-Shannon Theorem: A signal can be perfectly reconstructed if sampled at rate $f_s > 2f_{max}$.

At 44.1 kHz: $f_{max} = 22.05$ kHz

Z-Transform

The z-transform converts discrete signals to the z-domain:

$$X(z) = \sum_{n=-\infty}^{\infty} x[n] z^{-n}$$

Unit delay: $z^{-1}$ (one sample delay)

Transfer Functions

Lowpass filter (1-pole): $$H(z) = \frac{1-p}{1-pz^{-1}}$$

Where $p = e^{-2\pi f_c / f_s}$

State-Variable Filter: $$\begin{aligned} \text{LP} &= \text{LP}{n-1} + f \cdot \text{BP}{n-1} \ \text{HP} &= \text{input} - \text{LP} - q \cdot \text{BP}{n-1} \ \text{BP} &= f \cdot \text{HP} + \text{BP}{n-1} \end{aligned}$$

Waveform Mathematics

Sine Wave

$$x(t) = A \sin(2\pi f t + \phi)$$

Sawtooth (Band-Limited)

Fourier series: $$x(t) = \frac{2}{\pi} \sum_{k=1}^{\infty} \frac{(-1)^{k+1}}{k} \sin(2\pi k f t)$$

Square Wave

$$x(t) = \frac{4}{\pi} \sum_{k=1,3,5,…}^{\infty} \frac{1}{k} \sin(2\pi k f t)$$

Only odd harmonics!

Triangle Wave

$$x(t) = \frac{8}{\pi^2} \sum_{k=1,3,5,…}^{\infty} \frac{(-1)^{(k-1)/2}}{k^2} \sin(2\pi k f t)$$

Envelope Mathematics

Exponential Segments

Attack (charging capacitor): $$v(t) = V_{max} (1 - e^{-t/\tau})$$

Decay/Release (discharging): $$v(t) = V_{start} \cdot e^{-t/\tau}$$

Time constant $\tau$: time to reach $1 - 1/e \approx 63.2%$

RC Time Constant

$$\tau = RC$$

For envelope times: $\tau = \text{time} / \ln(1000) \approx \text{time} / 6.9$

FM Synthesis

Basic FM Equation

$$y(t) = A \sin(2\pi f_c t + I \sin(2\pi f_m t))$$

  • $f_c$: Carrier frequency
  • $f_m$: Modulator frequency
  • $I$: Modulation index

Sidebands

FM produces sidebands at: $$f_c \pm n \cdot f_m \quad (n = 1, 2, 3, …)$$

Number of significant sidebands ≈ $I + 1$

Bessel Functions

Amplitude of each sideband given by Bessel functions: $$A_n = J_n(I)$$

Filter Response

Pole-Zero Form

$$H(z) = \frac{\sum_{k=0}^{M} b_k z^{-k}}{\sum_{k=0}^{N} a_k z^{-k}}$$

Cutoff Frequency

For bilinear transform: $$\omega_d = \frac{2}{T} \tan\left(\frac{\omega_a T}{2}\right)$$

Resonance (Q)

$$Q = \frac{f_0}{\Delta f}$$

Where $\Delta f$ is bandwidth at -3dB.

High Q → narrow peak → self-oscillation

Analog Modeling

Thermal Noise

$$V_n = \sqrt{4kTRB}$$

  • $k$: Boltzmann constant
  • $T$: Temperature (K)
  • $R$: Resistance
  • $B$: Bandwidth

Saturation Functions

Tanh (soft): $$y = \tanh(x \cdot \text{drive})$$

Polynomial (3rd order): $$y = x - \frac{x^3}{3}$$

Asymmetric: $$y = \tanh(a \cdot x^+) - \tanh(b \cdot x^-)$$

V/Oct System

Pitch to Frequency

$$f = f_0 \cdot 2^V$$

$f_0 = 261.63$ Hz (C4) at 0V

Frequency to Pitch

$$V = \log_2\left(\frac{f}{f_0}\right)$$

Semitone

$$\Delta V = \frac{1}{12} \text{ V} \approx 83.33 \text{ mV}$$

Cent

$$\Delta V = \frac{1}{1200} \text{ V} \approx 0.833 \text{ mV}$$

SIMD Mathematics

Vectorized Operations

For 4-wide SIMD: $$[a_1, a_2, a_3, a_4] + [b_1, b_2, b_3, b_4] = [a_1+b_1, a_2+b_2, a_3+b_3, a_4+b_4]$$

Single instruction, multiple data.

Block Processing

Process $N$ samples per function call:

  • Reduces function call overhead by factor of $N$
  • Enables vectorization
  • Improves cache locality

References

  • Smith, J.O. Mathematics of the Discrete Fourier Transform
  • Välimäki, V. Discrete-Time Synthesis of the Sawtooth Waveform
  • Mac Lane, S. Categories for the Working Mathematician
  • Chowning, J. The Synthesis of Complex Audio Spectra by Means of FM