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.

Diagrams like this one are backed by live, hearable versions — start with the Explorables. Or patch the real compiled engine right now in the Live Playground.

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: build a patch, run it,
//! and this time actually *hear* it — the render finishes by writing a real
//! `.wav` file to disk.
//!
//! Run with: cargo run --example quick_taste

use quiver::prelude::*;
use quiver::render::write_wav;
use std::path::Path;

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. `render` (from `quiver::render`, re-exported
    // in the prelude) just repeatedly calls `patch.tick()` for you — it's the
    // same thing as a manual loop, but it's also what writes WAV files below.
    let (left, right) = render(&mut patch, 1.0);

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

    // --- Hear it! ---
    // Quiver's `Audio` ports use a modular-synth convention of +-5V, but a
    // `.wav` file's samples are full-scale +-1.0, so we divide by 5 before
    // writing (see the `# Sample scale` note on `quiver::render`).
    let to_full_scale = |buf: &[f64]| -> Vec<f64> { buf.iter().map(|s| s / 5.0).collect() };
    let wav_path = Path::new("target/quick_taste.wav");
    write_wav(
        wav_path,
        44100,
        &to_full_scale(&left),
        &to_full_scale(&right),
    )
    .expect("failed to write WAV file");
    println!(
        "\nWrote {} - play it in any audio player to hear Quiver's output!",
        wav_path.display()
    );
}

Run it with cargo run --example quick_taste—it writes target/quick_taste.wav, so you can hear the result in any audio player. For a fuller sequenced phrase rendered to disk, see cargo run --example render_wav and the Render Offline to WAV guide.

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.78+ (2021 edition) — this is Quiver’s MSRV (Minimum Supported Rust Version)
  • Cargo (comes with Rust)

Verify your installation:

rustc --version
cargo --version

Adding Quiver to Your Project

As a Dependency

Add to your Cargo.toml. The package is published on crates.io as quiver-dsp (the bare name quiver was already taken by an unrelated crate), but the library name is still quiver — so your code writes use quiver::prelude::* regardless:

[dependencies]
quiver-dsp = "0.2"

Or with specific features:

[dependencies]
quiver-dsp = { version = "0.2", features = ["simd"] }

To track the development branch instead, use a git dependency:

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

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)
wasmNoWebAssembly bindings via wasm-bindgen + TypeScript types via tsify (implies alloc). See Browser & App Integration.

Feature Tiers

Quiver supports three tiers for different environments:

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

no_std, but still requires a global allocator — the core patch graph uses Box/Vec/String, so you must provide a #[global_allocator] (there is no allocator-free tier). Suitable for embedded systems that have a heap:

[dependencies]
quiver-dsp = { version = "0.2", 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-dsp = { version = "0.2", 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-dsp = "0.2"

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.

Every module here has a live explorable: follow this exact signal path.

The Complete Example

//! First Patch Example
//!
//! A complete subtractive synthesizer voice demonstrating the core
//! Quiver workflow: VCO → VCF → VCA with ADSR envelope shaping.
//!
//! How this differs from `simple_patch.rs`: that example is the bare-minimum
//! patch (just a VCO wired to the output, no envelope or gate at all). This
//! one is the fuller voice you'd actually use in a synth — a gate signal
//! triggers an ADSR envelope, which shapes both the filter's cutoff and the
//! VCA's amplitude, so a "note" has a distinct attack and release instead of
//! playing as a static, unchanging tone.
//!
//! 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!");
}

Run it with cargo run --example first_patch. To hear a patch instead of just printing samples, see Render Offline to WAV.

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.

Reading the Circuit Diagrams

The patch diagrams in this book color every port and cable by the kind of signal it carries: audio, CV, gates & clocks, V/Oct pitch, and modulation. A minimal voice looks like this:

Every signal type described on this page also has an interactive, hearable counterpart in the Explorables section.

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.then(filter);

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

// Fanout: f &&& g
let split = fx1.fanout(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.then(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.then(vcf).then(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.then(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.

Live Playground

Open the Playground →

The playground is a full polyphonic synthesizer running Quiver’s actual WASM engine — the same compiled Rust that ships in the @quiver-dsp/wasm npm package. Nothing is emulated: the patch graph is compiled and ticked sample-by-sample inside an AudioWorklet on the audio render thread.

What you can do there:

  • Play it — click Initialize Audio, then use your computer keyboard (AK for white keys, WETYU for black keys), the on-screen keys, or a connected MIDI controller.
  • Shape the voice — a 4-voice subtractive patch (VCO → SVF → VCA with ADSR and chorus) with live controls for waveform, pulse width, detune, cutoff, resonance, envelope amount, ADSR times, and chorus.
  • Watch it — oscilloscope, Lissajous (stereo phase), bar, and spectrum views, plus per-channel VU meters, all tapped from the worklet’s output.
  • Save and load patches — the same JSON patch format the Rust library reads and writes (schemas/patch.schema.json).
  • Browse the module catalog — every module the WASM build exposes, with its ports and signal types, queried live from the engine’s registry.

How it relates to the Explorables

The Explorables are small, instant-loading JavaScript mirrors of individual DSP algorithms — built for understanding one idea at a time. The playground is the opposite end of the spectrum: the complete engine, compiled from the Rust source, patched and played in real time. When you want to check that what you learned holds up in the real thing, this is where you go.

Running it locally

The playground is the browser demo in demos/browser:

make browser-synth   # builds the WASM package and starts a dev server on :3000

See Browser & App Integration for using the same engine in your own app.

About the Explorables

Reading about a filter tells you that resonance boosts the cutoff frequency. Dragging a resonance knob while the frequency response reshapes under your pointer tells you what resonance is. This section is a set of explorable explanations — in the tradition of Bret Victor’s reactive documents and 3Blue1Brown’s animated mathematics — where every plot is live, every number in the prose can be scrubbed, and everything you see can also be heard.

Two promises hold on every page:

  1. The math is the library’s math. The widgets don’t sketch textbook approximations — they run the same formulas as Quiver’s Rust source: the PolyBLEP oscillator from oscillators.rs, the exact TPT filter response from filters.rs, the envelope stage machine from dynamics.rs. What you drag is what ships.
  2. What you see is what you hear. The ▶ buttons play the very buffer being plotted, at the voltage conventions of the library (±5 V audio, scaled down to your speakers). Sound never plays until you ask.

The color language

Signals are colored by kind, consistently across prose, plots, and patch diagrams:

  • audio — the sound itself, ±5 V
  • CV — control voltages that shape it, like envelopes
  • gate / trigger / clock — 0 or +5 V timing signals
  • V/Oct — pitch, one volt per octave, 0 V = C4
  • modulation — LFOs and secondary movement

When you see a dashed number in the text, drag it sideways (or focus it and use arrow keys) — the figures respond immediately.

The pages

  • The Shape of a Wave — waveforms and their harmonic recipes, and why the oscillator must be bandlimited.
  • Sculpting the Spectrum — the state-variable filter’s exact frequency response, from gentle rolloff to self-oscillation.
  • Envelopes Shape Time — drag an ADSR by its corners and hear loudness become a contour.
  • The Geometry of Pitch — one volt per octave, and why exponential pitch looks like a line on a log axis.
  • Sidebands from Nothing — FM synthesis: two sines, a forest of partials, exactly where Bessel said they’d be.
  • Follow the Signal — the full subtractive voice as a clickable circuit; scope any cable.

These pages need JavaScript (and a reasonably modern browser). Animations respect your reduced-motion preference; audio always waits for a click.

The math on these pages is a hand-checked JavaScript mirror of the Rust source — small and instant, but a mirror. When you want the actual compiled engine, open the Live Playground: the full synthesizer, running Quiver’s WASM build in your browser.

The Shape of a Wave

Every classic synthesizer waveform is two pictures of the same object. In time, it is a shape — a curve the speaker cone traces, five volts up, five volts down. In frequency, it is a recipe — a stack of pure sine harmonics, each with its own strength. Neither picture is more real than the other; a trained ear hears the recipe, an oscilloscope shows the shape. Quiver’s Vco serves the four classics — sine, triangle, saw, square — and below, both pictures are computed from the same rendered buffer, so whatever you do to the shape you do to the recipe, instantly.

The oscillator is also where digital synthesis meets its oldest enemy. A mathematically perfect saw edge contains harmonics beyond any sample rate, and everything the samples cannot hold reflects back into the audio band as aliasing. Quiver bandlimits its edges with PolyBLEP (and its corners with PolyBLAMP). The toggle below turns that protection off — look at what floods the spectrum floor, then press ▶ hear it.

The small dial above the wave is the oscillator’s entire inner life. A Vco stores exactly one number — a phase \( \varphi \) that runs around a circle once per cycle — and each waveform is just a different function of where on the circle the phase currently is. The dial spins in slow motion (about one turn every two seconds, nowhere near audio rate) while the violet dot traces the matching point on the waveform: phase runs around the circle, and the shape is what the wave does along the way.

Pitch here is not a number in hertz — it is a voltage on the voct input. Drag it: 0.00 V. Every volt doubles the frequency — 1 V per octave, with 0 V = C4 (261.63 Hz) — so a five-octave keyboard is just a 5 V span of CV. The blue trace is the waveform in volts; the blue spectrum underneath is that exact buffer passed through an FFT, and the dashed yellow lines mark the integer harmonics \( n \cdot f_0 \) — with bandlimiting on, every peak sits on one. The hollow yellow rings are the prediction, not the measurement: the closed-form Fourier amplitude of each harmonic for the current shape, anchored to the measured fundamental so theory and FFT share a reference. Watching the stems land exactly on the rings is watching a 200-year-old theorem pass a live test. The square wave has one more knob the others lack: its pulse width, pw = 0.50, the fraction of each cycle spent high.

Things to try

  1. Pick sin. The spectrum is a single spike at \( f_0 \) — a sine is one harmonic and nothing else. Every other waveform on this page is built out of copies of it.
  2. Cycle saw → sqr → tri and read the recipes: the saw has every harmonic falling off as \( 1/n \) (a straight −6 dB/octave staircase); the square keeps only the odd harmonics, also at \( 1/n \); the triangle keeps the odd ones but at \( 1/n^2 \), which is why its spectrum plunges and it sounds so much mellower than the square whose harmonics sit at the same frequencies.
  3. Pick sqr and drag the pulse width off 0.50: the even harmonics fade back in — at exactly 0.5 they cancel perfectly. Park it near 0.10 for the thin, nasal pulse timbre every string-machine patch is built on. Hear it while you drag.
  4. Pick saw, drag the pitch up to +4.00 V, and switch bandlimited off. The floor fills with hash that lands between the yellow lines — harmonics above Nyquist reflected to frequencies that are not multiples of \( f_0 \). Press ▶ hear it and flip the toggle back and forth: the naive saw rings with inharmonic, metallic junk.
  5. Still naive, scrub the pitch slowly upward while listening: the true harmonics rise, but the aliases sweep downward. Partials that bend the wrong way under a pitch change are the unmistakable fingerprint of aliasing.
  6. Drop to −2.00 V, still naive. The damage nearly vanishes — the offending harmonics are weak and few down here. This is why naive oscillators almost get away with bass lines and fall apart the moment you play a lead.
  7. Pick sqr and watch the phasor: the square’s value is just which part of the circle the phase is in — inside the shaded slice the wave sits at +5 V, outside at −5 V. Drag pw and the slice and the wave’s duty cycle move together. Now picture the saw the same way: its value is simply how far around the circle am I, climbing from −5 V back to −5 V once per lap.
  8. With bandlimited on, every measured stem lands on a hollow ring — the FFT agreeing with Fourier’s closed-form recipe, live. Switch it off at +4.00 V and the stems miss and smear off their targets: the theory did not fail, the sampling did. (Drag pw on the square and the rings themselves migrate — the recipe below is the pw = 0.5 special case of the general pulse wave.)

What you just saw

The spectrum panel is not a decoration next to the waveform — it is the waveform, written in a different basis. Fourier’s theorem says any repeating wave is a sum of sines at integer multiples of the fundamental, and the classic shapes have closed-form recipes. The sawtooth uses every harmonic:

\[ \mathrm{saw}(t) = \frac{2}{\pi} \sum_{n=1}^{\infty} \frac{(-1)^{n+1}}{n} \sin(2\pi n f_0 t) \]

The square and triangle use only the odd ones, at \( 1/n \) and \( 1/n^2 \) respectively:

\[ \mathrm{sqr}(t) = \frac{4}{\pi} \sum_{n\ \mathrm{odd}} \frac{1}{n} \sin(2\pi n f_0 t) \qquad \mathrm{tri}(t) = \frac{8}{\pi^2} \sum_{n\ \mathrm{odd}} \frac{(-1)^{(n-1)/2}}{n^2} \sin(2\pi n f_0 t) \]

Those coefficients are exactly the stem heights you saw: \( 1/n \) is a −6 dB/octave slope, \( 1/n^2 \) is −12 dB/octave. The pitch voltage feeds the exponential V/Oct law, \( f_0 = 261.63 \times 2^{V} \) — one volt, one octave — which is why equal drags of the scrub felt like equal musical intervals.

The sums above are infinite, and there lies the problem: a sampled system at rate \( f_s \) can only represent content below the Nyquist frequency \( f_s / 2 \). A harmonic at \( f > f_s/2 \) does not disappear — it folds back to \( f_s - f \), which is almost never a multiple of \( f_0 \): inharmonic hash. PolyBLEP exists precisely for this. Instead of a perfect instantaneous edge (an infinite series), it splices in a two-sample polynomial rounding whose spectrum falls off steeply before Nyquist — the wave you see with the toggle on is microscopically “wrong” in time and audibly right in frequency.

The Quiver code

The widget’s bandlimited math is Vco::tick, line for line. In a real patch the same oscillator is four lines of graph setup — its inputs are voct, fm, pw, sync, and fm_lin; its outputs are sin, tri, saw, and sqr, all ±5 V audio:

use quiver::prelude::*;

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

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

// Patch the sawtooth straight to the left channel. The unpatched `voct`
// input sits at 0 V, so the VCO free-runs at C4 (261.63 Hz) — exactly the
// widget's default. (StereoOutput normals the right input to the left.)
patch.connect(vco.out("saw"), output.in_("left")).unwrap();

// Compile once, then tick: each call advances the graph one sample.
patch.set_output(output.id());
patch.compile().unwrap();
let (left, _right) = patch.tick();

Swap "saw" for "sin", "tri", or "sqr" to pick a different recipe, and patch a CV source into "pw" to sweep the square’s pulse width the way you just scrubbed it.

Go deeper

  • Tutorial: Subtractive Synthesis — patch this VCO into a filter and envelope and make the recipe move.
  • Reference: Oscillators — every port of Vco, AnalogVco, Supersaw, Wavetable, and the rest of the sources.
  • Concepts: Signals — the voltage conventions this page leaned on: ±5 V audio, 1 V/octave pitch, 0 V = C4.
  • Next explorable: Sculpting the Spectrum — you just met a wave as a stack of harmonics; next, carve that stack with a filter.

Next: Sculpting the Spectrum

Sculpting the Spectrum

A sawtooth wave is the block of marble: every harmonic is in there, the n-th at 1/n amplitude, a comb of partials stretching to the top of hearing. A filter is the chisel. Subtractive synthesis is exactly what the name says — you start with everything and carve away what you don’t want. Below, the carving happens in front of you: the ghost comb is the raw saw, the bold curve is the filter, and the solid comb is what survives. Drag directly on the plot and press ▶ hear it — the sound you hear is the literal buffer behind the solid curve.

The ghosted comb is the input: a sawtooth at A2 · 110 Hz, straight from Quiver’s bandlimited Vco. The bold curve is the filter’s magnitude response — not a textbook sketch but the exact discrete response of Quiver’s Svf, evaluated from the same math that runs in Svf::tick. The cutoff knob sits at CV 0.50; the module maps that knob exponentially, \( f_c = 20 \cdot 1000^{\text{cv}} \) Hz, so CV 0 is 20 Hz, CV 0.5 is ~632 Hz, and CV 1 is 20 kHz — every hundredth of knob travel is the same musical distance, about a ninth of an octave. That is why the dot glides evenly across the log axis instead of spending 95% of its travel above 1 kHz. Resonance is at 0.20, which sets the damping \( k = 2 - 2\cdot\text{res} \) — the sharpness of the peak at the cutoff. The solid comb is the output: the same saw pushed sample-by-sample through the filter. Watch it as you drag — every harmonic lands exactly where the ghost comb meets the response curve, because the output is the input times the response.

The slim strip under the spectrum replays the same story in time: three cycles of the raw saw (ghosted) with the filtered output drawn over it. A sawtooth’s razor edge is nothing but high harmonics in phase, so as the lowpass takes them away the corners visibly melt — the ramp survives, the snap rounds off. And the ◠ sweep button performs the most famous gesture in synthesis for you: the cutoff rides from closed to open and back over a couple of seconds, each position leaving a fading ghost of the response curve behind — a long-exposure photograph of a filter sweep. Your own cutoff setting is untouched and the plot returns to it when the sweep lands.

Things to try

  1. Sweep the cutoff down in LP mode, slowly, from 1.00 toward 0. The harmonics don’t fade together — they disappear one at a time, from the top down, and the sound darkens while the pitch never moves. That ordered demolition is subtractive synthesis.
  2. Isolate a single harmonic: switch to BP, push res to 0.95+, and drag the cutoff dot onto the third partial (330 Hz for A2). One tooth of the comb survives at full height; the saw becomes a near-sine. A filter this sharp is a harmonic selector.
  3. Find the self-oscillation: set res to 1.00. The damping floors at \( k \approx 0 \) and the peak explodes off the top of the plot — the filter rings at the cutoff frequency even between harmonics, adding a pitch of its own. Press ▶ hear it and drag the cutoff: you’re playing the filter. (The Rust module soft-clips its two integrator states, so this near-lossless resonator sustains a bounded whistle instead of diverging.)
  4. Notch = LP + HP: flip between LP, HP, and Notch at the same cutoff. The notch curve is the other two summed — and in Svf::tick it literally is: notch = low + high. Everything cancels only where the two responses overlap in antiphase, right at the cutoff.
  5. Feel the knob law: scrub the cutoff CV 0.25 → 0.50 → 0.75. Each equal step slides the curve an equal distance on the log axis — equal octaves per knob-degree. A linear-in-Hz knob would cram all the musically useful range into its first few degrees.
  6. Remove the fundamental: in HP mode, sweep the cutoff up past 110 Hz. The lowest partial vanishes first, yet the ear still hears the same pitch — the surviving harmonics imply the missing fundamental.
  7. Watch the corners melt: in LP mode, drag the cutoff from 0.9 down to about 0.3 while watching the time strip. The saw’s edges are made of high harmonics, so they soften first; by the time only a few partials survive, the razor has become a wave. Flip to HP and the opposite happens — the body drains away and only the snap of the edge remains.
  8. Take a long exposure: press ◠ sweep with res around 0.6. The trail of fading curves is the whole family of responses one knob can draw — the resonant bump gliding along the log axis at constant width, which is exactly why a filter sweep sounds like a vowel morph and not a volume change. (With reduced motion enabled, five static snapshots appear instead.)

What you just saw

The state-variable filter computes all four responses at once from one two-integrator core. With \( g \) the integrator gain and \( k \) the damping, the transfer functions are

\[ H_{lp}(s) = \frac{g^2}{D(s)}, \qquad H_{bp}(s) = \frac{g s}{D(s)}, \qquad H_{hp}(s) = \frac{s^2}{D(s)}, \]

all sharing the denominator

\[ D(s) = s^2 + k g s + g^2, \qquad k = \frac{1}{Q} = 2 - 2\cdot\text{res}. \]

The notch is the sum of the extremes, \( H_{notch} = (g^2 + s^2)/D \), and the three primary outputs obey the identity \( H_{lp} + k H_{bp} + H_{hp} = 1 \) — the core splits the input, it never invents energy. At res = 0 the damping is \( k = 2 \) (Q = 0.5, no peak); as res → 1, \( k \to 0 \) and Q → ∞: the poles slide onto the unit circle and the filter becomes a sine oscillator at \( f_c \).

Quiver’s Svf is a Zavalishin topology-preserving-transform (zero-delay feedback) discretization of that analog prototype, with the prewarped coefficient

\[ g = \tan\left(\frac{\pi f_c}{f_s}\right). \]

The bilinear transform squeezes the analog frequency axis into the digital one, and the tangent prewarp bends \( f_c \) in advance by exactly the amount the squeeze will undo — so the cutoff lands where you asked all the way toward Nyquist, where the older Chamberlin core (coefficient \( 2\sin(\pi f_c/f_s) \)) froze above roughly \( f_s/6 \). It also makes the bold curve honest: evaluating the prototype at \( s = j\tan(\pi f / f_s) \) gives the exact response of the digital filter, and that is precisely what the plot draws.

The Quiver code

The widget is this patch. Svf takes the audio at in, the knob CVs at cutoff and res (plus fm, keytrack, and keytrack_amt for modulation), and produces lp, bp, hp, and notch simultaneously — patch whichever mode you want:

use quiver::prelude::*;

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

// Sound source and filter — the ghost comb and the bold curve.
let vco = patch.add("vco", Vco::new(sample_rate));
let vcf = patch.add("vcf", Svf::new(sample_rate));

// The two knobs, as constant CVs (0-1, exponential cutoff law inside).
let cutoff = patch.add("cutoff", Offset::new(0.5)); // 20·1000^0.5 ≈ 632 Hz
let res = patch.add("res", Offset::new(0.2))        // k = 2 - 2·0.2 = 1.6

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

// Saw → filter → lowpass tap → out. Swap "lp" for "bp"/"hp"/"notch".
patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
patch.connect(cutoff.out("out"), vcf.in_("cutoff")).unwrap();
patch.connect(res.out("out"), vcf.in_("res")).unwrap();
patch.connect(vcf.out("lp"), output.in_("left")).unwrap();
patch.connect(vcf.out("lp"), output.in_("right")).unwrap();

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

let (left, _right) = patch.tick(); // one filtered sample, in volts

Go deeper

  • Tutorial: Filter Modulation — drive the cutoff with an LFO and the static curve above starts to move.
  • Reference: Filters — the full Svf and DiodeLadderFilter port maps.
  • Next explorable: Shaping Time — the filter sculpts the spectrum; the envelope sculpts when you hear it.

Next: Shaping Time

Envelopes Shape Time

A raw oscillator is a voltage that never changes its mind — press a key and it drones at full amplitude forever. Real notes have a shape: a hammer strike that blooms and dies, a bowed swell, a pad that breathes in slowly. In a modular synth that shape is itself a voltage. The ADSR envelope listens to a gate — the key going down — and answers with a contour from 0 to 10 V that other modules obey. Loudness is a contour, not a constant. Grab the curve below and bend it.

The primary interaction here is dragging the handles on the curve itself — this is the envelope you would otherwise dial in with four knobs. The violet curve is the module’s env output (its internal 0–1 level times 10 V); the green bar is the gate, high from the moment the key goes down until you let go — 0.80 s later (scrub that number, or drag the marker on the bar). Drag the attack peak and the release end left and right to set their times; drag the decay corner sideways for decay time and up or down for the sustain level. Watch the readouts: each stage shows both its time and the 0–1 knob CV that produces it — the module’s time inputs sweep 1 ms to 10 s exponentially, so the first few pixels of a drag move milliseconds and the last few move whole seconds. The blue band underneath is the same envelope multiplied into a saw wave at C3 — press ▶ hear it (or gate, which is just pressing the key again) and that exact buffer plays. Below that, the knob law strip plots all three time knobs on the module’s actual law, \( T(\text{cv}) = 0.001 \cdot 10000^{\text{cv}} \), drawn on a log-time axis where an exponential is a straight line — drag a time handle and watch its marker slide along the very curve your finger is feeling. Finally, the retrigger mid-release toggle simulates a second key press arriving halfway through the release: the coral second pass shows the module’s real behavior — the new attack climbs from the current level, never resetting to zero.

Things to try

  1. Make a pluck. Drag the attack peak hard left (about 1 ms), the decay corner left to ~100 ms and all the way down so sustain is zero. The gate length now barely matters — the note is over before the key comes up. Hear it: that’s a mallet, a pizzicato, a bass stab.
  2. Make a pad. Drag the attack peak right until attack is 1–2 s and raise sustain near the top. The note now arrives instead of starting. Notice the time axis rescaling to keep the whole story on screen.
  3. Sustain is a level, not a time. With sustain at 0.6, scrub the gate length from 0.1 s to 4 s. Attack, decay, and release never change — only the flat sustain shelf stretches. The player owns that segment, not the module.
  4. Flip on exponential stages and compare releases. The linear ramp ends with an audible corner; the one-pole curve loses a constant fraction of its level per unit time — like a real string, a real RC circuit — and simply fades below hearing. That’s why exponential releases sound more natural.
  5. The release time is honest. Set sustain low (0.2) and release to ~1 s, and watch the release segment: it still takes the full labeled time to reach zero. The module scales the release rate by the level it captured at gate-off, so “release = 1 s” means one second from wherever the envelope was, not from an imaginary full-scale peak.
  6. Cut a stage short. Make the attack longer than the gate. The envelope never reaches the peak — the gate falls mid-climb and the release begins from the current level. Envelopes follow the key, not the plan.
  7. Retrigger mid-release. Flip on retrigger mid-release and hear it: the key comes up, the note starts dying, and a second key press lands halfway through the release. The coral pass climbs from wherever the level is — this is legato playing, and it is why envelopes continue from the current level. A zero-reset would snap the voltage to 0 in one sample and click on every fast repeated note.
  8. Watch the knob law. Drag the attack peak slowly from hard left to hard right while watching the A marker in the strip below. It rides a straight line on log-time paper — equal pixels of drag multiply the time by equal factors. That single law governs every time knob in the module.

What you just saw

The widget runs the same stage machine as Adsr::tick. In linear mode (the shape input at 0 V) each segment is a constant per-sample rate, scaled by the span it actually has to traverse:

\[ \text{attack\_rate} = \frac{1}{A \cdot f_s}, \qquad \text{decay\_rate} = \frac{1 - S}{D \cdot f_s}, \qquad \text{release\rate} = \frac{\ell{\text{gate-off}}}{R \cdot f_s} \]

where \( A, D, R \) are the stage times in seconds, \( S \) is the sustain level, and \( \ell_{\text{gate-off}} \) is the level captured the instant the gate falls — the scaling from “things to try” #5. In exponential mode each stage becomes a one-pole approach toward its target (1 for attack, \( S \) for decay, 0 for release), with the stage time as the time constant:

\[ c = e^{-1/(T f_s)}, \qquad \ell \leftarrow \ell + (\text{target} - \ell)(1 - c) \]

Every step closes a fixed fraction \( 1 - c \) of the remaining distance, which is exactly why the curve is steep at first and asymptotic at the end. And the knob law you felt while dragging — fine near the left, coarse near the right — is the exponential map from a 0–1 CV to seconds:

\[ T(\text{cv}) = 0.001 \cdot 10000^{\text{cv}} \]

so cv = 0 is 1 ms, cv = 0.5 is 100 ms, and cv = 1 is 10 s: each quarter turn of the knob multiplies the time by ten.

The Quiver code

The classic patch: a gate presses the envelope’s key, and the envelope’s 0–10 V contour drives a VCA sitting on the audio path — voltage shaping voltage, exactly what the widget draws.

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

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

// A key press, as voltage: this line goes 0 V -> +5 V -> 0 V.
let gate_cv = Arc::new(AtomicF64::new(0.0));
let gate = patch.add("gate", ExternalInput::gate(Arc::clone(&gate_cv)));

let vco = patch.add("vco", Vco::new(sample_rate));
let env = patch.add("env", Adsr::new(sample_rate));
let vca = patch.add("vca", Vca::new());
let out = patch.add("out", StereoOutput::new());

// The gate presses the envelope's key.
patch.connect(gate.out("out"), env.in_("gate")).unwrap();

// Audio path: raw saw through the VCA.
patch.connect(vco.out("saw"), vca.in_("in")).unwrap();
patch.connect(vca.out("out"), out.in_("left")).unwrap();
patch.connect(vca.out("out"), out.in_("right")).unwrap();

// The envelope's 0-10 V `env` output drives the VCA's `cv`:
// loudness IS the contour you just dragged.
patch.connect(env.out("env"), vca.in_("cv")).unwrap();

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

gate_cv.set(5.0); // key down: attack -> decay -> sustain...
// ... tick() for the length of the note ...
gate_cv.set(0.0); // key up: release, from the current level

The Adsr’s attack, decay, sustain, and release are themselves CV inputs (0–1 through the knob law above), so anything — an LFO, a sequencer row, another envelope — can reshape the shape. It also offers a retrig trigger input (restart the attack from the current level without dropping the gate — the same never-reset-to-zero behavior the retrigger toggle above demonstrates), an inv output (the contour upside down, for ducking), and an eoc end-of-cycle trigger for chaining. Set the shape input high (+5 V) for the exponential stages you toggled above.

Go deeper

  • Tutorial: Envelope Shaping builds this patch step by step and modulates the filter with the same contour.
  • Reference: Modulators — the full Adsr port list, plus the LFO and the other contour generators.
  • Next explorable: One Volt per Octave — the other CV convention every module agrees on: pitch as voltage.

Next: One Volt per Octave

The Geometry of Pitch

In a modular synthesizer, pitch is not a note name, a MIDI number, or a frequency dial — it is a voltage on a wire. The whole standard fits in one sentence: one volt is one octave, and 0 V is middle C. Everything else about musical pitch — semitones, cents, why an octave “sounds the same,” why detuning by a few millivolts makes a chorus — falls out of a single exponential, \( f(V) = 261.63 \cdot 2^{V} \). Drag the marker below and watch the geometry happen.

The yellow ruler is the pitch CV — the voltage a sequencer or keyboard actually sends down a V/Oct cable. The blue curve above it is the frequency a VCO turns that voltage into, plotted on a linear Hz axis so you can feel the exponential: each volt to the right doesn’t add frequency, it doubles it. Set the pitch CV to +0.750 V and read the marker — at exactly +0.750 V you get A4 = 440 Hz, nine semitones above middle C. Now switch quantize to semitones on: the marker snaps to the nearest multiple of 1/12 V while a faint ghost keeps tracking the raw voltage. That snap is Quiver’s Quantizer module — a pitch quantizer is nothing more than rounding a voltage to the semitone grid. The piano strip riding on the ruler makes the volts↔notes bijection tactile: every key sits on an exact multiple of 1/12 V, so tapping E4 is setting the CV to +0.333 V. And switch on detune demo to hear the raw pitch and its quantized ghost sounding together: the slow swelling and fading you hear — and see in the envelope strip that appears — is beating, the audible form of the cents readout, undulating at exactly \( |f_{raw} - f_{quantized}| \) times per second. The lower strip replots the same curve on a log-frequency axis, where it becomes a perfectly straight line. This is why engineers love log axes for pitch: equal musical intervals are equal frequency ratios, and ratios only become equal distances on a log axis.

Things to try

  1. Drag the marker up by exactly +1 V from anywhere — from 0 V to +1 V, or from −1.317 V to −0.317 V — and watch the frequency readout exactly double. The starting point never matters; only the voltage distance does.
  2. Each semitone is 1/12 V ≈ 83.3 mV. Turn quantize on and drag slowly: the raw ghost glides continuously while the marker holds, then jumps a whole 83.3 mV step at once — a staircase built from a ramp.
  3. Scrub the voltage by just a few millivolts (arrow keys on the scrub work too). The cents readout shows how far off-pitch you are: 1 cent is a mere 0.83 mV, which is why analog oscillators need precise, temperature-stable volt-per-octave circuits.
  4. Compare the two plots at the top of the range. From +2 V to +3 V the linear plot rockets from about 1046 Hz to 2093 Hz — the same 1 V that only moved you 65 Hz down at −2 V. On the log strip below, those two steps are identical distances. Same geometry, different lens.
  5. Press play interval: you hear the marker’s note, then the note exactly +1.0 V above it — twice the frequency — then both together. That fused, “same-note-but-higher” quality is what a 2:1 ratio sounds like.
  6. Park the marker at +0.750 V and press ▶ hear it: the orchestra’s tuning A, produced by nothing but \( 261.63 \cdot 2^{0.75} \).
  7. Tap C4, then C#4, then D4 on the piano strip and watch the raw CV climb by exactly 83.3 mV per key — the keyboard is a voltage ladder, one rung per semitone.
  8. Turn on detune demo and tap E4: the envelope strip is flat — raw and quantized agree, so there is nothing to beat. Now drag a hair sharp and count the slow swells: at +5 ¢ they come about once per second, and the beat rate readout predicts exactly the rate you count, \( |f_{raw} - f_{quantized}| \approx 0.95\ \text{Hz} \).

What you just saw

The V/Oct standard is one function. With \( f_{C4} = 261.6255653\ \text{Hz} \) (the 0 V reference), a pitch voltage \( V \) maps to frequency as

\[ f(V) = f_{C4} \cdot 2^{V}. \]

Because the volt lives in the exponent, adding voltage multiplies frequency: \( f(V + 1) = 2 f(V) \) is the octave, and dividing the volt into twelve equal slices gives the equal-tempered semitone as a ratio,

\[ \text{one semitone} = \tfrac{1}{12}\ \text{V} \ \Longrightarrow\ f\left(V + \tfrac{1}{12}\right) = 2^{1/12} f(V) \approx 1.0595 f(V). \]

Cents subdivide the semitone a hundred times further. The widget’s cents readout is the distance from the nearest note on the semitone grid:

\[ \text{cents}(V) = 100 \left( 12V - \operatorname{round}(12V) \right), \]

so 1 cent is 1/1200 V ≈ 0.83 mV. Finally, taking \( \log_2 \) of the pitch law explains the straight line in the lower strip:

\[ \log_2 f = \log_2 f_{C4} + V. \]

On a log-frequency axis, frequency is a linear function of voltage — slope exactly one octave per volt. Pitch is geometry: intervals are ratios, and the log axis is the space in which those ratios become plain distances. The quantizer is just \( V \mapsto \operatorname{round}(12V)/12 \), rounding in that space.

The Quiver code

The same three ideas as the widget — a pitch voltage, a semitone quantizer, and the exponential VCO — as a real patch. Offset with nothing patched into its input is the idiomatic constant-CV source; Quantizer snaps it to the chromatic grid (the real module also adds hysteresis so a CV parked on a boundary doesn’t chatter between notes); the Vco applies \( f = 261.63 \cdot 2^{V} \) internally via voct_to_hz.

use quiver::prelude::*;

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

    // A constant pitch CV: +0.762 V — a few cents sharp of A4, on purpose.
    let pitch = patch.add("pitch", Offset::new(0.762));

    // Snap to the semitone grid: round(12·V)/12. The quantizer commits
    // +0.750 V — exactly A4.
    let quant = patch.add("quant", Quantizer::new(Scale::Chromatic));

    // The VCO turns volts into Hz: f = 261.63 · 2^V, so +0.750 V -> 440 Hz.
    let vco = patch.add("vco", Vco::new(sample_rate));
    let output = patch.add("output", StereoOutput::new());

    patch.connect(pitch.out("out"), quant.in_("in")).unwrap();
    patch.connect(quant.out("out"), vco.in_("voct")).unwrap();
    patch.connect(vco.out("sin"), output.in_("left")).unwrap();

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

    // Render one second of the tuning A.
    for _ in 0..sample_rate as usize {
        let (_left, _right) = patch.tick();
    }
}

Swap Scale::Chromatic for Scale::Major or Scale::PentatonicMinor and the same rounding idea snaps to a musical scale instead of all twelve semitones — that’s the entire difference between Quantizer’s modes.

Go deeper

  • Reference: V/Oct Reference — the complete note/voltage/frequency table this page is built on.
  • Concept: Signals — where VoltPerOctave sits among Quiver’s signal kinds.
  • Tutorial: Sequenced Bass — a step sequencer emitting these very voltages into a VCO.
  • Next explorable: Two Oscillators, One Wire — what happens when the thing modulating pitch is itself an oscillator.

Next: Two Oscillators, One Wire

Sidebands from Nothing

Vibrato is a slow wobble in pitch — a modulator bending a carrier a few times a second. Speed the wobble up a thousandfold, past the point where the ear can follow the pitch moving, and it stops being vibrato and becomes timbre. Two pure sine waves, no filter anywhere in sight, and a forest of partials appears — not at random, but at positions you can predict to the hertz. That is FM synthesis, and the whole trick is below. The yellow stems are the prediction; the blue curve is the measurement of the exact buffer you can hear.

The carrier is the sine you hear, pitched at 0.00 V on the V/Oct scale (0 V = C4 ≈ 261.6 Hz). The modulator — the ghosted violet sine in the top plot — runs at the carrier’s frequency and never reaches your ears directly: its only job is to bend the carrier’s frequency up and down. How hard it bends is the modulation index I = 3.0, the peak frequency deviation divided by the modulator’s frequency, \( I = \Delta F / f_m \). Scrub all three and watch the predicted stems and the measured spectrum move in lockstep.

Two smaller views close the loop. The Bessel panel graphs the first five weights \( J_0 \) through \( J_4 \) against the index, with a yellow cursor parked at the current I — the stem heights in the spectrum are literally these curves sampled at the cursor, and the dots on the zero line mark \( J_0 \)’s first two zeros, 2.405 and 5.520, where the carrier’s own stem vanishes. The index waterfall underneath records a sweep: during bloom (or instantly, via the waterfall button) each column paints the predicted spectrum at one value of I, low frequencies at the bottom — so you can watch the sidebands fan outward in Bessel order as the index rises, frozen into one picture.

One honest disclosure: the widget computes the classic phase-modulation form, \( y(t) = 5\sin(2\pi f_c t + I \sin(2\pi f_m t)) \) volts, because that is the parameterization the sideband math is written in. It has exactly the spectrum of sinusoidal linear FM with peak deviation \( \Delta F = I f_m \), which is what Quiver’s Vco does on its fm_lin jack: the module computes freq += (fm_lin / 5) · base, so a modulator swinging ±depth volts produces \( \Delta F = (\text{depth}/5) f_c \), and therefore \( I = \frac{\text{depth}}{5} \cdot \frac{f_c}{f_m} \). To dial an index of 3 at a 2× ratio you need a ±30 V modulator swing — which is why FM patches put a gain stage between the operators.

Things to try

  1. Set the ratio to . Every sideband lands exactly on a harmonic of the carrier — fc, 2fc, 3fc… — and the tone turns sawlike. The readout says harmonic.
  2. Set the ratio to . Sidebands land at fc ± 2k·fc: only the odd harmonics, and the reflected ones fold back onto the same grid. Hollow, squarelike — the same recipe a clarinet uses.
  3. Set the ratio to 3.75×. Now the sidebands miss the harmonic grid and the readout flips to inharmonic: this is where bells, gongs, and metal live.
  4. Scrub the index down to 0 — a lone stem, a pure sine. Now raise it slowly and watch the spectrum’s width track the Carson bandwidth readout, ≈ 2(I+1)fm: each unit of index wakes roughly one more sideband pair.
  5. Find I ≈ 2.4. The center stem — the carrier itself — drops into the noise floor, because \( J_0(2.4048) = 0 \): the carrier vanishes from its own spectrum while everything around it keeps ringing. The next disappearance is at I ≈ 5.5.
  6. Press bloom: the index sweeps from 0 to its target and the sidebands grow outward in order, each k-th pair waking only once I catches up to k — Bessel functions, animated. The waterfall strip freezes the whole sweep into one picture: sidebands fanning outward as the columns march right.
  7. Find I ≈ 2.4 on the Bessel panel — the blue \( J_0 \) curve crosses zero exactly where the carrier disappears from its own spectrum. Park the yellow cursor on the dot and watch the center stem die; the next blackout waits at 5.520.
  8. Set the index to 12, press waterfall, then flip the ratio between and 3.75× and press it again: harmonic ratios print clean horizontal stripes, inharmonic ones weave a dense unaligned fabric.

What you just saw

The entire widget is one identity. A sine whose phase is wiggled by another sine is exactly a sum of sines at evenly spaced frequencies:

\[ \sin\bigl(2\pi f_c t + I \sin(2\pi f_m t)\bigr) = \sum_{k=-\infty}^{\infty} J_k(I) \sin\bigl(2\pi (f_c + k f_m) t\bigr) \]

The weights \( J_k(I) \) are the Bessel functions of the first kind — the yellow stem heights. Each one answers “how much energy lands k steps away from the carrier at this index?” At \( I = 0 \), \( J_0 = 1 \) and every other \( J_k = 0 \): a bare carrier. As I grows, \( J_k(I) \) stays near zero until \( I \approx k \), then swells — which is why sidebands appear in order, and why the audible bandwidth obeys Carson’s rule, \( B \approx 2(I+1)f_m \). Each \( J_k \) also oscillates in I, passing through zeros — at \( I \approx 2.405 \) it is \( J_0 \)’s turn, and the carrier itself blinks out. Energy is never created, only redistributed: \( \sum_k J_k(I)^2 = 1 \) for every I. When \( f_c + k f_m \) goes negative, \( \sin(-\omega t) = -\sin(\omega t) \): the component reflects back to \( |f_c + k f_m| \), which is why low carriers with big ratios grow extra stems near the bottom — that fold is through-zero FM, the oscillator’s phase briefly running backwards.

This math belongs to linear FM only, and the Vco gives you both flavors for a reason. The linear input adds deviation symmetrically — \( f_c(1 + m\sin\omega t) \) averages to exactly \( f_c \), so the note stays in tune at any depth. The exponential fm input multiplies instead: \( f_c \cdot 2^{a\sin\omega t} \), and because \( 2^x \) is convex, the average of \( 2^{a\sin\omega t} \) is greater than 1 — the upward swings outweigh the downward ones and the perceived pitch climbs as depth grows. Lovely for vibrato at small depths, hopeless for tuned FM timbres. That is why classic Chowning FM is patched into fm_lin.

The Quiver code

The two-operator patch behind everything above — modulator sine, a gain stage to set the depth (and thus the index), into the carrier’s linear through-zero FM input:

use quiver::prelude::*;

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

let carrier = patch.add("carrier", Vco::new(sample_rate));
let modulator = patch.add("modulator", Vco::new(sample_rate));
let depth = patch.add("depth", Attenuverter::new());
// fm = 2 x fc. V/Oct is logarithmic, so a frequency RATIO becomes a
// voltage OFFSET into the modulator's pitch input: log2(2.0) = 1 V.
let ratio_cv = patch.add("ratio_cv", Offset::new(2.0_f64.log2()));
let output = patch.add("output", StereoOutput::new());

patch.connect(ratio_cv.out("out"), modulator.in_("voct")).unwrap();
// Modulator sine -> depth -> the carrier's LINEAR (through-zero) FM input.
// `fm_lin` is the sideband math above; the exponential `fm` input is not.
patch.connect(modulator.out("sin"), depth.in_("in")).unwrap();
patch.connect(depth.out("out"), carrier.in_("fm_lin")).unwrap();
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();

The Attenuverter’s gain is level / 5 V (unity by default), so drive its level input above 5 V — an Offset works — for the >1 gains that big indices demand: index I at ratio r needs a modulator swing of 5 · I · r volts. To make the index move — the bright-attack electric-piano trick — patch an Adsr into that level input instead. The runnable version, with a sweep of ratios and depths, is examples/tutorial_fm.rs.

Go deeper

  • Tutorial: FM Synthesis Basics — operator algorithms, index envelopes, and the classic DX7 recipes.
  • Reference: Oscillators — the Vco’s full port map, including both FM inputs.
  • Next explorable: Patch Flow — how signals actually move through a compiled patch, one tick at a time.

Next: Patch Flow

Follow the Signal

A patch is not a program that runs once — it is a circuit where every cable carries a living voltage, all the time. The diagram below is the classic subtractive voice from Your First Patch: a gate triggers an envelope, while the oscillator’s audio runs through a filter and an amplifier. Click any module to put a scope on its output and watch the same note from six different points in the circuit. The strip of mini-scopes under the main scope shows all six stations at once — tap one to jump the big scope there — and while the note plays, a playhead sweeps the full-note view so you can match what you hear to where you are.

The voice plays a note at -1 V (1 V/octave, so −1 V is C3), with the gate held for 0.6 s. The filter sits at a base cutoff of 0.15 (knob CV, 0–1) with resonance 0.25, and the envelope pushes the cutoff up by 0.55 before falling back — the brightness of the note is a shape in time, not a setting.

Things to try

  1. Read the mini-scope strip left to right: rectangle → contour → tone → sculpted tone → note. That row is the patch — every station of the transformation in one glance. Tap any mini-scope to inspect it up close.
  2. Scope the VCO, then the SVF, then the VCA — the same 30 ms of waveform at three stations. The saw’s sharp corners melt at the filter, and the VCA finally gives the sound a beginning and an end.
  3. Turn zoom to waveform off while scoping the VCA, then press ▶ hear this point: the playhead sweeps the note’s whole amplitude contour — the ADSR curve, worn by the audio like a glove — while you listen.
  4. Scope the GATE and then the ADSR: a rectangle goes in, a contour comes out. Every knob on an envelope module is a statement about how to round off a rectangle.
  5. Drag the envelope→cutoff depth to 0: the note becomes static and dull — subtractive synthesis with nobody moving the tone control. Now drag it to 1 and listen to the attack spit.
  6. Raise resonance toward 1 with a low base cutoff: the filter starts to sing its own note on top of the saw (the SVF self-oscillates; its integrator states are soft-clipped in the Rust so this stays bounded).
  7. Shorten the gate to 0.1 s: the envelope never reaches sustain — you can see it turn around mid-decay when the gate falls.

What you just saw

Each cable color is a signal type, and each type is a voltage convention:

  • audio — the sound itself, ±5 V
  • CV — control voltage, here the envelope’s 0–10 V
  • gate — 0 V or +5 V, “the key is down”
  • V/Oct — pitch, one volt per octave, 0 V = C4

The VCA is nothing but a multiplication,

\[ \text{out}(t) = \text{in}(t) \cdot \frac{\text{cv}(t)}{10~\text{V}}, \]

and the filter’s cutoff knob follows an exponential law so that equal knob motion covers equal musical distance,

\[ f_c(\text{cv}) = 20 \cdot 1000^{\text{cv}}~\text{Hz}, \qquad \text{cv} \in [0, 1]. \]

The envelope output is 10 V at full level, while the cutoff CV wants 0–1 — in hardware you would patch through an attenuverter, and Quiver’s cutoff port carries one for exactly this reason. The scrub for depth above is that attenuverter.

The Quiver code

This is the real example the diagram mirrors — module for module, cable for cable (examples/first_patch.rs):

//! First Patch Example
//!
//! A complete subtractive synthesizer voice demonstrating the core
//! Quiver workflow: VCO → VCF → VCA with ADSR envelope shaping.
//!
//! How this differs from `simple_patch.rs`: that example is the bare-minimum
//! patch (just a VCO wired to the output, no envelope or gate at all). This
//! one is the fuller voice you'd actually use in a synth — a gate signal
//! triggers an ADSR envelope, which shapes both the filter's cutoff and the
//! VCA's amplitude, so a "note" has a distinct attack and release instead of
//! playing as a static, unchanging tone.
//!
//! 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!");
}

Run it with cargo run --example first_patch.

Go deeper

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

The patch itself is three modules plus a fixed Offset that parks the filter cutoff at a musical spot:

Hear the filter carve these exact harmonics away in Sculpting the Spectrum.

//! Tutorial: Basic Subtractive Synthesis
//!
//! This example demonstrates the fundamentals of subtractive synthesis:
//! starting with a harmonically rich oscillator and shaping it with a filter.
//!
//! # Why subtractive synthesis works
//!
//! A sawtooth wave already contains *every* harmonic of its fundamental
//! (1st, 2nd, 3rd, ... all present, falling off as 1/n). Subtractive
//! synthesis doesn't add color, it removes it: a lowpass filter attenuates
//! everything above its cutoff, leaving a subset of that harmonic series.
//! Sweeping the cutoff changes which harmonics survive, which is why an
//! otherwise-static saw wave can sound like it "opens up" as the filter
//! tracks upward. The state-variable filter (`Svf`) used here also exposes
//! resonance (`res`), which boosts energy right at the cutoff frequency —
//! more resonance emphasizes that edge harmonic, giving the classic
//! "squelchy" synth-filter character, and at very high resonance the filter
//! can self-oscillate into a pure sine at the cutoff frequency.
//!
//! Run with: cargo run --example tutorial_subtractive

use quiver::prelude::*;
use quiver::render::write_wav;
use std::path::Path;

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).
    // Why: the Svf maps its cutoff CV exponentially over its 0-1 input range
    // (20Hz .. ~20kHz), so a fixed voltage picks a fixed brightness rather
    // than a fixed Hz value — this mirrors how a hardware VCF's cutoff knob
    // works. 0.35 lands the cutoff a couple of harmonics above the C4
    // fundamental (~261Hz), audibly darkening the sawtooth without muting it.
    let cutoff = patch.add("cutoff", Offset::new(0.35));

    // 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.");

    // --- Hear it! ---
    // Render a couple more seconds from the same (already-compiled) filtered
    // patch to a real .wav file. Quiver's Audio ports are +-5V; WAV files are
    // full-scale +-1.0, so we scale down before writing (see the
    // `# Sample scale` note on `quiver::render`).
    let (wav_left, wav_right) = render(&mut patch, 2.0);
    let to_full_scale = |buf: &[f64]| -> Vec<f64> { buf.iter().map(|s| s / 5.0).collect() };
    let wav_path = Path::new("target/tutorial_subtractive.wav");
    write_wav(
        wav_path,
        sample_rate as u32,
        &to_full_scale(&wav_left),
        &to_full_scale(&wav_right),
    )
    .expect("failed to write WAV file");
    println!(
        "\nWrote {} - play it to hear the filtered sawtooth!",
        wav_path.display()
    );
}

Listen to It

Run cargo run --example tutorial_subtractive and the example writes target/tutorial_subtractive.wav—open it in any audio player to hear the filter shape the raw sawtooth.

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

In this patch the four ADSR stages are not knob settings — they are CV inputs, each fed by an Offset module. The envelope shapes only the VCA, so what you hear is the pure volume contour:

Drag the four stages yourself and watch the contour respond in Envelopes Shape Time.

//! Tutorial: Envelope Shaping
//!
//! Demonstrates the ADSR envelope generator and how it shapes sound over time.
//! This is fundamental to giving synthesized sounds their character.
//!
//! # Why ADSR has four stages
//!
//! A held note isn't a static event — it has a beginning, a middle, and an
//! end, and each needs different timing:
//! - **Attack**: the time to rise from silence to full level once the gate
//!   opens. Fast (a few ms) reads as percussive/plucky; slow (100s of ms)
//!   reads as a swell/pad fade-in.
//! - **Decay**: the time to fall from that initial peak down to the
//!   **Sustain** level — not a duration but a *held level* (0-1), the
//!   volume the note stays at for as long as the gate remains open. This is
//!   what makes a plucked-string patch (high decay, low sustain — the pluck
//!   dies down to near-silence and stays there) sound different from an
//!   organ patch (sustain near 1.0 — it just holds).
//! - **Release**: the time to fall from wherever the level was when the
//!   gate closed back to zero. Short release = clipped/staccato; long
//!   release = notes bleed into each other.
//!
//! Quiver's `Adsr` maps each stage's CV input onto an exponential 1ms-10s
//! time range, so small CV changes near the low end make a big perceptual
//! difference (just like a real synth's time knobs).
//!
//! 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());

    // Time-constant CVs. `Adsr` maps each 0-1 CV onto an exponential
    // 1ms-10s range via `time = 0.001 * 10000^cv`, so these values were
    // picked by solving that formula for the target times noted below —
    // slow enough that the millisecond checkpoints in the loops below land
    // inside each stage instead of after it has already finished.
    let attack_cv = patch.add("attack_cv", Offset::new(0.62)); // ~300ms attack
    let decay_cv = patch.add("decay_cv", Offset::new(0.5)); // ~100ms decay
    let sustain_cv = patch.add("sustain_cv", Offset::new(0.5)); // hold at 50% level
    let release_cv = patch.add("release_cv", Offset::new(0.64)); // ~350ms release

    // Connections
    patch.connect(gate.out("out"), env.in_("gate")).unwrap();
    patch
        .connect(attack_cv.out("out"), env.in_("attack"))
        .unwrap();
    patch
        .connect(decay_cv.out("out"), env.in_("decay"))
        .unwrap();
    patch
        .connect(sustain_cv.out("out"), env.in_("sustain"))
        .unwrap();
    patch
        .connect(release_cv.out("out"), env.in_("release"))
        .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: run the patch for `n` samples and return the peak amplitude
    // seen. A *peak* (not just the last sample) is what we want here — a
    // single instantaneous sample would just be wherever the sawtooth
    // carrier happened to be in its cycle, not a meaningful envelope
    // reading.
    fn run_samples(patch: &mut Patch, n: usize) -> f64 {
        let mut peak = 0.0_f64;
        for _ in 0..n {
            let (left, _) = patch.tick();
            peak = peak.max(left.abs());
        }
        peak
    }

    // 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: with a ~300ms attack time, these checkpoints show
    // the level climbing rather than already sitting at full scale.
    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);
    }

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

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

    // With a ~350ms release, the level should ease down across these
    // checkpoints instead of hitting zero immediately.
    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);
    }

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

Run it with cargo run --example tutorial_envelope.

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.

Watch a moving cutoff reshape the spectrum in real time in Sculpting the Spectrum.

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.
//!
//! # Why modulating the cutoff creates movement
//!
//! A static filter cutoff makes a static timbre — useful, but lifeless.
//! Driving the cutoff with a Low-Frequency Oscillator (an LFO: an
//! oscillator running well below audible range, here a fraction of a Hz to
//! a few Hz) continuously changes *which harmonics survive* the lowpass,
//! so the same sawtooth cycles between dark and bright without anyone
//! touching a knob. This differs from an audio-rate oscillator only in
//! frequency, not in kind — Quiver's `Lfo` and `Vco` share the same
//! waveform shapes for exactly this reason. The LFO's *waveform* changes
//! the character of the sweep: a sine gives a smooth, natural swell; a
//! triangle gives a linear ramp; a square gives an instant on/off "gate"
//! effect instead of a sweep at all. The cutoff itself still follows the
//! `Svf`'s exponential CV-to-Hz mapping (see `tutorial_subtractive.rs`), so
//! equal LFO excursions produce equal *musical* (octave) jumps in cutoff,
//! not equal Hz jumps.
//!
//! 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: the cutoff CV's useful range is 0-1 (see
    // tutorial_subtractive.rs), so 0.5 centers it at a medium brightness the
    // LFO can swing both up and down from.
    let cutoff_base = patch.add("cutoff_base", Offset::new(0.5));

    // Why an Attenuverter here: the LFO's `sin` output swings a full +-5V
    // (audio-signal scale), but the cutoff CV only usefully spans 0-1V. Fed
    // in raw, the sum would spend almost the whole cycle pinned at one
    // extreme (fully open or fully closed) instead of sweeping smoothly.
    // Scaling it down to +-0.5V keeps `cutoff_base +- lfo` inside [0, 1] for
    // the whole cycle, so the sweep is continuous rather than a hard switch.
    let lfo_depth = patch.add("lfo_depth", Attenuverter::new());
    // Attenuverter gain = level / 5V, so level = 0.5 gives gain = 0.1,
    // turning the +-5V LFO into a +-0.5V cutoff excursion.
    let lfo_depth_cv = patch.add("lfo_depth_cv", Offset::new(0.5));

    // 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 → attenuator → Filter cutoff (with base offset)
    patch
        .connect(cutoff_base.out("out"), vcf.in_("cutoff"))
        .unwrap();
    patch
        .connect(lfo_depth_cv.out("out"), lfo_depth.in_("level"))
        .unwrap();
    patch.connect(lfo.out("sin"), lfo_depth.in_("in")).unwrap();
    patch.connect(lfo_depth.out("out"), 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. Peak amplitude is a
        // rough but effective proxy for brightness here: a sawtooth's energy
        // is concentrated in its lower harmonics, so cutting it down
        // (closing the filter) shaves off amplitude along with treble.
        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");
}

Run it with cargo run --example tutorial_filter_mod.

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.

Why does one volt equal one octave? Scrub the pitch yourself in The Geometry of Pitch.

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.
//!
//! # Why a step sequencer instead of manual gate/pitch control
//!
//! Earlier tutorials (`tutorial_envelope.rs`, `first_patch.rs`) drove pitch
//! and gate signals from Rust code directly. A `StepSequencer` moves that
//! job *into the graph*: it holds up to 8 (V/Oct pitch, gate-on/off) pairs
//! and advances one step every time its `clock` input receives a rising
//! edge, outputting that step's stored CV and gate. This is exactly how a
//! hardware step sequencer module works — the tempo comes from a separate
//! `Clock` module, decoupled from the pattern itself, so the same 8-step
//! bassline can run at any tempo just by changing the clock's rate.
//!
//! Two things worth understanding about the signal chain below:
//! - **V/Oct pitch**: each step stores a control voltage where every extra
//!   1V is one octave up (`(midi_note - 60) / 12` converts a MIDI note to
//!   this scale, since 12 semitones = 1 octave = 1V).
//! - **Gate-gated triggering**: the sequencer only asserts its gate output
//!   while the clock pulse itself is high *and* that step is marked "on" —
//!   a "rest" step in the pattern lets the ADSR's release tail finish
//!   naturally instead of re-triggering, which is what keeps a rest sounding
//!   like silence rather than a stuck note.
//!
//! Run with: cargo run --example tutorial_sequenced_bass

use quiver::prelude::*;

/// Convert a MIDI note number to a V/Oct control voltage (0V = MIDI 60 / C4).
fn midi_to_voct(note: u8) -> f64 {
    (note as f64 - 60.0) / 12.0
}

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

    // 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
    ];

    // Step sequencer - stores our bassline pattern. Steps must be programmed
    // with `set_step(index, voct, gate)` *before* the module is handed to
    // `patch.add`: once a module is inside the graph it's only reachable by
    // port name, not by its concrete Rust type, so this is the only chance
    // to configure it directly.
    let mut seq_module = StepSequencer::new();
    for (i, (note, active)) in pattern.iter().enumerate() {
        seq_module.set_step(i, midi_to_voct(*note), *active);
    }
    let seq = patch.add("seq", seq_module);

    // Master clock - sets the tempo. `out` is the main pulse (2 Hz / 120 BPM
    // by default); `div2`/`div4` divide it further for slower sub-patterns.
    // We use the un-divided `out` so the 8-step pattern advances once per
    // pulse.
    let clock = patch.add("clock", Clock::new(sample_rate));

    // 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("out"), 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");

    fn note_name(note: u8) -> &'static str {
        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",
            _ => "?",
        }
    }

    println!("Bassline pattern (now actually programmed into the sequencer):");
    for (i, (note, active)) in pattern.iter().enumerate() {
        if *active {
            let voct = midi_to_voct(*note);
            let octave = (note / 12) - 1;
            println!(
                "  Step {}: {}{} ({:.3}V)",
                i + 1,
                note_name(*note),
                octave,
                voct
            );
        } else {
            println!("  Step {}: rest", i + 1);
        }
    }

    // The clock's main "out" pulses at 2 Hz by default (120 BPM), so each of
    // the 8 steps lasts half a second — one full pass through the pattern
    // takes 4 seconds.
    //
    // One quirk worth knowing: `Clock`'s phase starts at 0, which is already
    // inside its pulse window, so the very first sample tick delivers a
    // rising edge before we've heard anything — the sequencer advances past
    // step 0 in zero time. Since the pattern loops forever in a real patch,
    // this just means playback effectively starts one step ahead; we offset
    // our step index by one below so the printed labels match what's
    // actually sounding.
    let step_samples = (sample_rate * 0.5) as usize;
    println!("\nRunning one pass through the pattern (4.0s)...\n");

    for i in 0..pattern.len() {
        let (note, active) = pattern[(i + 1) % pattern.len()];
        let mut peak = 0.0_f64;
        for _ in 0..step_samples {
            let (left, _) = patch.tick();
            peak = peak.max(left.abs());
        }

        let label = if active {
            format!("{}{}", note_name(note), (note / 12) as i32 - 1)
        } else {
            "rest".to_string()
        };
        let bar = "█".repeat((peak * 2.0) as usize);
        println!(
            "Step {} ({:>4}): {:5.2}V |{}",
            (i + 1) % pattern.len() + 1,
            label,
            peak,
            bar
        );
    }

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

Run it with cargo run --example tutorial_sequenced_bass.

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.

See the sidebands appear as you scrub ratio and index in Sidebands from Nothing.

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.
//!
//! # Why FM sounds the way it does
//!
//! Modulating a carrier's instantaneous frequency with another oscillator
//! (the modulator) doesn't just add one extra pitch — it generates a whole
//! family of new frequencies called *sidebands*, symmetric around the
//! carrier: `fc ± n * fm` for every integer `n = 1, 2, 3, ...`, where `fc`
//! is the carrier frequency and `fm` is the modulator frequency. How many of
//! those sidebands carry audible energy (and how loud each one is) is
//! governed by the **modulation index**, `I = deviation / fm` — the peak
//! frequency deviation the modulator pushes the carrier through, divided by
//! the modulator's own frequency. `I = 0` is just the bare carrier; as `I`
//! grows, energy spreads into more and more sidebands (their amplitudes
//! follow Bessel functions `J_n(I)`), which is why increasing the
//! modulation index alone brightens/thickens a tone even with a fixed
//! carrier:modulator ratio.
//!
//! This example uses `fm_lin`, the VCO's *linear*, through-zero FM input,
//! specifically because linear FM is what produces the textbook sidebands
//! above — the alternative `fm` input is *exponential* (each volt is an
//! octave, good for vibrato/pitch bends) and does not follow the same
//! sideband math. The carrier:modulator frequency ratio determines whether
//! those sidebands land back on harmonics of the carrier (integer ratios,
//! e.g. 1:2, 1:3 — sounds "musical"/harmonic) or land in between them
//! (irrational/non-integer ratios, e.g. 1:1.414 — sounds bell-like or
//! metallic/inharmonic).
//!
//! 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 *linear* FM input (`fm_lin`).
    // Why `fm_lin` and not `fm`: only the linear input adds a frequency
    // deviation directly (`freq += (fm_lin / 5) * base_freq`), which is what
    // produces the `fc +- n*fm` sidebands described above. The exponential
    // `fm` input multiplies frequency instead, which is the right shape for
    // vibrato but doesn't follow the same sideband formula.
    patch
        .connect(modulator.out("sin"), mod_depth.in_("in"))
        .unwrap();
    patch
        .connect(mod_depth.out("out"), carrier.in_("fm_lin"))
        .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 (carrier:modulator ratio, modulation depth) pairs. The
    // modulation index I = depth / ratio (see the doc comment above), so the
    // printed value is computed, not guessed.
    for (name, ratio, depth) in [
        ("Pure carrier (no FM)", 1.0_f64, 0.0_f64),
        ("Subtle FM", 1.0, 0.5),
        ("Medium FM", 1.0, 1.5),
        ("Heavy FM", 1.0, 3.0),
        ("Bell (1:sqrt(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());
        // Sets the modulator's pitch to `ratio` times the carrier's: V/Oct is
        // logarithmic (1V = 1 octave = 2x frequency), so a frequency ratio
        // becomes a voltage offset of log2(ratio).
        let mod_ratio_cv = test_patch.add("mod_ratio_cv", Offset::new(ratio.log2()));
        // Attenuverter gain = level / 5V (see Attenuverter's doc), so driving
        // `level` with `depth * 5.0` makes the attenuverter's gain equal
        // `depth` directly.
        let depth_cv = test_patch.add("depth_cv", Offset::new(depth * 5.0));
        let output = test_patch.add("output", StereoOutput::new());

        // Set up FM with the given parameters
        test_patch
            .connect(mod_ratio_cv.out("out"), modulator.in_("voct"))
            .unwrap();
        test_patch
            .connect(depth_cv.out("out"), mod_depth_node.in_("level"))
            .unwrap();
        test_patch
            .connect(modulator.out("sin"), mod_depth_node.in_("in"))
            .unwrap();
        test_patch
            .connect(mod_depth_node.out("out"), carrier.in_("fm_lin"))
            .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);
        // I = deviation / modulator_frequency = depth / ratio (both already
        // expressed relative to the carrier frequency).
        let modulation_index = if ratio > 0.0 { depth / ratio } else { 0.0 };

        println!("{}", name);
        println!(
            "  C:M ratio = 1:{:.3}, modulation index I = {:.2}",
            ratio, modulation_index
        );
        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 (I = deviation/fm) controls brightness and complexity.");
}

Run it with cargo run --example tutorial_fm.

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.
//!
//! # Why voice allocation is its own problem
//!
//! A single VCO/VCF/VCA chain can only play one note at a time. Polyphony
//! means running several of those chains ("voices") in parallel and
//! deciding, for each incoming note, *which* voice plays it:
//! - With fewer voices than notes played at once, something has to give —
//!   that's what `AllocationMode` governs (steal the oldest note? the
//!   quietest one? refuse the new note entirely?). Real keyboards make this
//!   same tradeoff; even 16-32 voices can be exhausted by a sustain pedal
//!   held through a fast run.
//! - Mixing N simultaneous voices multiplies their combined peak amplitude
//!   roughly by N if they're in phase, so naively summing voices can clip.
//!   `PolyPatch` applies gain compensation (dividing by roughly `sqrt(N)`,
//!   matching how uncorrelated signals combine in power rather than
//!   amplitude) so a 4-note chord doesn't come out 4x louder than one note.
//! - `PolyPatch::with_voice_fn` builds one identical copy of your voice
//!   graph per voice and wires a per-voice controller into it exposing
//!   `voct`/`gate`/`trigger`/`velocity` — the same four signals a
//!   monophonic patch would drive by hand (see `tutorial_envelope.rs`),
//!   just supplied automatically by the allocator instead of external inputs
//!   you manage yourself.
//!
//! 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);
    }

    // ------------------------------------------------------------------
    // Building an ACTUAL polyphonic synthesizer with PolyPatch
    // ------------------------------------------------------------------
    // PolyPatch inserts an in-graph voice controller into each voice, so the
    // allocator's per-voice pitch/gate signals actually reach real DSP. Here
    // each voice is: voice controller -> Vco -> Vca (shaped by an Adsr) -> out.
    println!("\n--- Audible PolyPatch (4 voices) ---\n");

    let sample_rate = 48_000.0;
    let mut synth = PolyPatch::with_voice_fn(num_voices, sample_rate, |patch, ctrl| {
        let sr = patch.sample_rate();
        let vco = patch.add("vco", Vco::new(sr));
        let adsr = patch.add("adsr", Adsr::new(sr));
        let vca = patch.add("vca", Vca::new());
        let out = patch.add("out", StereoOutput::new());
        // The controller exposes voct / gate / trigger / velocity outputs.
        patch.connect(ctrl.out("voct"), vco.in_("voct"))?;
        patch.connect(ctrl.out("gate"), adsr.in_("gate"))?;
        patch.connect(vco.out("saw"), vca.in_("in"))?;
        patch.connect(adsr.out("env"), vca.in_("cv"))?;
        patch.connect(vca.out("out"), out.in_("left"))?;
        patch.set_output(out.id());
        Ok(())
    })
    .expect("failed to build voice graph");

    // Play the Cmaj7 chord. Voices output modular-level (~±5 V) audio.
    for &note in &chord {
        synth.note_on(note, 100);
    }
    // Let the envelopes settle past the onset transient, then measure the
    // gain-compensated steady-state peak (1/sqrt(N) keeps chords from clipping).
    for _ in 0..(sample_rate as usize / 10) {
        synth.tick();
    }
    let mut peak = 0.0f64;
    for _ in 0..(sample_rate as usize / 10) {
        let (l, r) = synth.tick();
        peak = peak.max(l.abs()).max(r.abs());
    }
    println!(
        "  {} voices sounding; gain-compensated steady peak = {:.3} (audible, bounded)",
        synth.allocator().active_count(),
        peak
    );

    // Release the chord: the ADSR release tails complete before voices free,
    // and the summed output is gain-compensated so chords do not clip.
    synth.all_notes_off();
    for _ in 0..(sample_rate as usize / 4) {
        synth.tick();
    }
    println!(
        "  After release: {} voices still freeing (tails complete, not truncated).",
        synth.allocator().active_count()
    );

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

The PolyPatch API

PolyPatch::with_voice_fn(voices, sample_rate, build) builds one voice graph per voice by calling your closure. The closure receives a fresh Patch and a voice controller (ctrl) whose outputs — voct, gate, trigger, and velocity — carry the allocator’s per-voice control values into the graph:

use quiver::prelude::*;

let sr = 48_000.0;
let mut poly = PolyPatch::with_voice_fn(4, sr, |patch, ctrl| {
    let sr = patch.sample_rate();
    let vco = patch.add("vco", Vco::new(sr));
    let adsr = patch.add("adsr", Adsr::new(sr));
    let vca = patch.add("vca", Vca::new());
    let out = patch.add("out", StereoOutput::new());

    // The controller exposes voct / gate / trigger / velocity.
    patch.connect(ctrl.out("voct"), vco.in_("voct"))?;
    patch.connect(ctrl.out("gate"), adsr.in_("gate"))?;
    patch.connect(vco.out("saw"), vca.in_("in"))?;
    patch.connect(adsr.out("env"), vca.in_("cv"))?;
    patch.connect(vca.out("out"), out.in_("left"))?;
    patch.set_output(out.id());
    Ok(())
})
.unwrap();

poly.note_on(60, 100); // MIDI note, velocity (0-127)
let (_l, _r) = poly.tick();
poly.note_off(60);

What PolyPatch handles for you:

  • Automatic voice freeing: each voice’s real output level is tracked by an amplitude follower, so a voice returns to Free only once its release tail has actually decayed — not the instant the gate falls.
  • Releasing-first voice stealing: when all voices are busy, voices already in Releasing are stolen before sounding ones (see the allocation modes for how a sounding victim is then chosen).
  • 1/sqrt(N) level compensation: the mix is scaled by an equal-power factor that is smoothed, so stacking or releasing voices never steps the master level.

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 detuned voices with UnisonConfig:

// 3 voices per note, spread 12 cents apart.
let config = UnisonConfig::new(3, 12.0);
poly.set_unison(config);

The slight detuning creates a chorus-like richness. detune_offset(i) and pan_position(i) give the per-voice pitch offset and stereo pan.

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 the ways to connect modules in a Patch, from basic patching to attenuated and modulated cables. Every connection is made between two port references obtained from the NodeHandle that patch.add(...) returns.

Basic Connection

The fundamental operation connects an output port to an input port and returns a stable CableId:

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

let cable_id = patch.connect(vco.out("saw"), vcf.in_("in"))?;
  • vco.out("saw") — an output jack, returns a PortRef.
  • vcf.in_("in") — an input jack. Spelled in_() because in is a Rust keyword.

connect returns Result<CableId, PatchError>; the CableId stays valid even after other cables are removed, so hold onto it if you want to disconnect exactly this cable later.

Finding Port Names

Inspect a module’s PortSpec. inputs and outputs are Vec<PortDef>; each PortDef has an id, a name, and a kind (SignalKind):

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

for port in &spec.inputs {
    println!("in  {}: {} ({:?})", port.id, port.name, port.kind);
}
for port in &spec.outputs {
    println!("out {}: {} ({:?})", port.id, port.name, port.kind);
}

Common port names:

ModuleInputsOutputs
Vcovoct, fm, pw, sync, fm_linsin, tri, saw, sqr
Svfin, cutoff, res, fm, keytrack, keytrack_amtlp, bp, hp, notch
Adsrgate, retrig, attack, decay, sustain, release, shapeenv, inv, eoc
Vcain, cv, response, gainout
StereoOutputleft, right(patch output)

See the Module Reference for the full list per module.

Connection with Attenuation

Scale a signal to 0–100% strength with connect_attenuated. The attenuation is clamped to 0.0..=1.0:

patch.connect_attenuated(
    lfo.out("sin"),
    vcf.in_("cutoff"),
    0.5, // 50% strength
)?;

Connection with Attenuation and Offset

For full attenuverter-plus-offset control, use connect_modulated:

patch.connect_modulated(
    lfo.out("sin"),
    vcf.in_("cutoff"),
    0.3,  // attenuation: -2.0..=2.0 (negative inverts, >1.0 amplifies)
    5.0,  // offset:      -10.0..=10.0 V, added after attenuation
)?;
  • attenuation is clamped to -2.0..=2.0. 1.0 is unity, 0.5 half strength, -1.0 inverts, 2.0 doubles (watch for clipping).
  • offset is clamped to -10.0..=10.0 volts and is added after attenuation. The example above shifts the LFO’s ±5 V swing to oscillate around +5 V.

Multiple Outputs (Mult)

One output can feed many inputs — connect it repeatedly, or use mult for a slice of destinations:

// The same gate triggers three 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"))?;

// Or in one call:
patch.mult(gate.out("out"), &[env1.in_("gate"), env2.in_("gate"), env3.in_("gate")])?;

Multiple Inputs (Summing)

Several cables into one input are summed, modeling how CVs mix at a hardware jack:

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

Normalled Inputs

Some modules declare normalled inputs in their port spec: when left unpatched, the input falls back to another port’s current value. For example, StereoOutput’s right input is normalled to left, so patching only left produces centered mono. Normalling is a property of the module definition (PortDef::normalled_to), resolved at compile time — you get the behavior automatically by leaving the input unpatched.

Validation Modes

Control how signal-kind mismatches are handled:

patch.set_validation_mode(ValidationMode::Strict); // error on mismatch
patch.set_validation_mode(ValidationMode::Warn);   // record a warning, allow it
patch.set_validation_mode(ValidationMode::None);   // no checking

The default is Warn, which flags questionable connections without blocking experimentation. In Strict mode an incompatible connection returns PatchError::SignalMismatch.

Disconnecting

Remove a specific cable by its CableId:

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

Or remove the cable between two specific ports:

patch.disconnect_ports(vco.out("saw"), vcf.in_("in"))?;

Error Handling

Connecting returns Result<CableId, PatchError>. PatchError is #[non_exhaustive], so a match needs a wildcard arm:

match patch.connect(a.out("x"), b.in_("y")) {
    Ok(cable_id) => println!("connected: {cable_id}"),
    Err(PatchError::InvalidPort { name, available, .. }) => {
        println!("no such port {name:?}; try one of {available:?}");
    }
    Err(PatchError::SignalMismatch { message, .. }) => {
        println!("signal mismatch: {message}");
    }
    Err(e) => println!("error: {e}"),
}

Note that feedback loops are not rejected at connect time — they surface as PatchError::CycleDetected from patch.compile() unless the loop passes through a cycle-breaking module such as UnitDelay or DelayLine.

Inspecting Connections

Query the current cables. cables() returns &[Cable], where each Cable has id, from, to, and optional attenuation / offset:

for cable in patch.cables() {
    println!("#{}: {:?} -> {:?}", cable.id, cable.from, cable.to);
}

Best Practices

  1. Name modules clearly"filter_lfo", not "lfo2".
  2. Keep Warn validation on during development to catch signal-kind mistakes.
  3. Check port specs when unsure of names, rather than guessing.
  4. Break feedback with a UnitDelay (or DelayLine) so compile() succeeds.
  5. Prefer attenuation over 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

MidiState decodes raw MIDI bytes into a set of shared Arc<AtomicF64> control values — pitch, gate, velocity, mod_wheel, pitch_bend, aftertouch, sustain, and expression — that you can wire straight into ExternalInput modules.

let midi = MidiState::new();

// In your MIDI callback, feed raw MIDI bytes:
midi.handle_message(&[0x90, 60, 100]); // Note On, note 60, velocity 100
midi.handle_message(&[0x80, 60, 0]);   // Note Off
midi.handle_message(&[0xB0, 1, 64]);   // CC1 (mod wheel)

// Read the decoded control values on the audio thread:
let current_voct = midi.pitch.get();
let current_gate = midi.gate.get();
let mod_value = midi.mod_wheel.get();

// Or share the atomics directly with the patch:
let pitch_in = patch.add("pitch", ExternalInput::voct(Arc::clone(&midi.pitch)));
let gate_in = patch.add("gate", ExternalInput::gate(Arc::clone(&midi.gate)));

For a coherent (pitch, gate) pair that can never tear, prefer midi.note_snapshot().

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)));
    // Not wired into the voice below (this demo only shows how to *receive*
    // velocity as CV); a real patch would feed it into a VCA or filter stage.
    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"],
  "output": "vca",
  "modules": [
    {
      "name": "vco",
      "module_type": "vco",
      "position": [100, 200],
      "state": null
    }
  ],
  "cables": [
    {
      "from": "vco.saw",
      "to": "vcf.in",
      "attenuation": 1.0
    }
  ],
  "parameters": {
    "vcf.cutoff": 0.6
  }
}

The output field records which module drives the patch’s stereo out. It is optional and additive — patches written before it existed simply omit it, and Patch::from_def falls back to an output-node heuristic. parameters maps "module_name.param_id" to a value, where param_id is either a control-input port name (its unpatched base value) or an internal parameter id exposed through introspection.

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 a factory with metadata:
registry.register_factory(
    "my_module",   // type_id
    "My Module",   // display name
    "effect",      // category
    "A custom effect", // description
    |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

Describe a patch either on the live Patch via PatchMeta (which survives a to_def/from_def round-trip) or directly on the PatchDef:

// On the live patch — carried through serialization:
patch.set_meta(PatchMeta {
    name: Some("Fat Bass".into()),
    author: Some("Sound Designer".into()),
    description: Some("Classic Moog-style bass with filter sweep".into()),
    tags: vec!["bass".into(), "moog".into(), "classic".into()],
});

// Or on the serialized def:
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()];

Parameters Round-Trip

Module parameters are captured and restored via introspection. to_def records each module’s parameters into PatchDef.parameters, and from_def re-applies them with set_param_by_id. You can inspect and drive parameters directly on a Patch:

// Discover a node's parameters:
for info in patch.param_infos(node_id) {
    println!("{} = {:?}", info.id, patch.get_param_by_id(node_id, &info.id));
}

// Set one by id (returns false if the id is unknown):
patch.set_param_by_id(node_id, "cutoff", 0.6);

Modules opt in to this by implementing GraphModule::introspect, which exposes their parameters to the GUI/serialization layer.

Versioning

PatchDef.version is checked against CURRENT_PATCH_VERSION. The format only grows additively, so the policy is: accept any patch with version <= CURRENT_PATCH_VERSION, and reject anything newer. Patch::from_def enforces this and returns an error for a patch written by a future version:

use quiver::serialize::CURRENT_PATCH_VERSION;

let def = PatchDef::from_json(&json)?;
assert!(def.version <= CURRENT_PATCH_VERSION); // from_def rejects newer patches

Preset Library

Use the built-in preset system:

let library = PresetLibrary::new();

// List all presets (associated functions — no receiver needed)
for preset in PresetLibrary::list() {
    println!("{}: {}", preset.name, preset.description);
}

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

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

// Load and build a preset (get returns Option, build returns Result)
if let Some(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

Render Offline to WAV

Render a patch offline—faster than real-time—and write the result to a standard .wav file you can play in any audio player. Requires the std feature (enabled by default).

Quick Version

render_to_wav does everything in one call:

use quiver::prelude::*;   // re-exports render and render_to_wav
use std::path::Path;

let mut patch = Patch::new(44100.0);
// ... add modules, connect, set_output ...

// Render 2 seconds and write a 16-bit PCM stereo WAV
render_to_wav(&mut patch, 2.0, Path::new("target/my_patch.wav"))?;

The patch is compiled lazily if needed, so a freshly-built patch renders without an explicit compile() call.

Rendering to Buffers

render returns raw (left, right) sample buffers so you can analyze or post-process before writing:

// Number of frames = round(seconds * patch.sample_rate())
let (left, right) = render(&mut patch, 1.0);

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

Rendering drives the same per-sample engine as patch.tick() with no per-frame allocation—the samples are identical to ticking one sample at a time.

Writing Buffers with write_wav

write_wav writes any pair of channel buffers (not in the prelude—import it from quiver::render):

use quiver::render::write_wav;

write_wav(Path::new("target/out.wav"), 44100, &left, &right)?;

If the channel lengths differ, the shorter length is used. The output is always 16-bit PCM stereo with the sample rate you pass.

Watch the Sample Scale

WAV samples are full-scale [-1.0, 1.0], but Quiver’s Audio ports follow the modular-synth ±5V convention. Both render_to_wav and write_wav treat the buffers as full-scale and clamp anything outside ±1.0—so a raw ±5V signal will clip hard at full scale.

Scale down before writing, either in the patch (e.g. through a Vca or Attenuverter) or on the rendered buffers:

// ±5V modular convention -> ±1.0 full scale
let to_full_scale = |buf: &[f64]| -> Vec<f64> { buf.iter().map(|s| s / 5.0).collect() };
write_wav(path, 44100, &to_full_scale(&left), &to_full_scale(&right))?;

Resonant filters can briefly overshoot 5V on sharp attacks; divide by a little more (e.g. 6.0) to leave headroom. There is no automatic normalization—what you pass is what gets written.

Sequencing While Rendering

Because render advances the patch in-place, you can call it repeatedly while changing control values between calls—for example driving pitch and gate via ExternalInput:

for &note in &[48u8, 52, 55, 60] {
    pitch_cv.set((note as f64 - 60.0) / 12.0);
    gate_cv.set(5.0);
    let (l, r) = render(&mut patch, 0.2);   // note on
    left_all.extend(l);
    right_all.extend(r);

    gate_cv.set(0.0);
    let (l, r) = render(&mut patch, 0.05);  // release tail
    left_all.extend(l);
    right_all.extend(r);
}

write_wav(path, 44100, &left_all, &right_all)?;

Complete Example

The render_wav example renders a sequenced arpeggio through a resonant filter to target/render_wav.wav. Run it with cargo run --example render_wav:

//! Render a Musical Phrase to WAV
//!
//! This is the "hear Quiver make sound" flagship example: a short sequenced
//! arpeggio through a resonant filter, shaped by an envelope, rendered
//! offline to a real `.wav` file you can play in any audio player.
//!
//! # Why this patch sounds the way it does
//!
//! - **V/Oct pitch**: the VCO's `voct` input follows the 1-volt-per-octave
//!   convention used by real modular synths — each additional volt doubles
//!   the oscillator frequency, and each `1/12`V step is one semitone. That's
//!   why converting a MIDI note to a control voltage is just
//!   `(note - 60) / 12.0` (MIDI note 60 = middle C = 0V here).
//! - **Envelope-to-filter modulation**: the same ADSR signal that shapes the
//!   VCA's amplitude also drives the filter's cutoff. This is the classic
//!   "plucked" synth-bass trick: the filter snaps open on the attack (bright
//!   pluck) and closes again as the envelope decays (a duller sustain/tail),
//!   all from one modulation source instead of two.
//! - **Gate vs. trigger timing**: each note holds its gate high for only
//!   80% of its slot before releasing, leaving an audible gap before the
//!   next note's attack — otherwise back-to-back notes at full sustain would
//!   blur together with no perceptible attack transient.
//!
//! Run with: cargo run --example render_wav

use quiver::prelude::*;
use quiver::render::write_wav;
use std::path::Path;
use std::sync::Arc;

/// Convert a MIDI note number to a V/Oct control voltage (0V = MIDI 60 / C4).
fn midi_to_voct(note: u8) -> f64 {
    (note as f64 - 60.0) / 12.0
}

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

    // External inputs stand in for a sequencer: we drive pitch and gate by
    // hand, one note at a time, in the loop below.
    let pitch_cv = Arc::new(AtomicF64::new(0.0));
    let gate_cv = Arc::new(AtomicF64::new(0.0));
    let pitch = patch.add("pitch", ExternalInput::voct(Arc::clone(&pitch_cv)));
    let gate = patch.add("gate", ExternalInput::gate(Arc::clone(&gate_cv)));

    // Voice: VCO -> VCF -> VCA, the same subtractive-synthesis chain as
    // first_patch.rs, but with the envelope also opening/closing the filter.
    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.connect(pitch.out("out"), vco.in_("voct")).unwrap();
    patch.connect(gate.out("out"), env.in_("gate")).unwrap();
    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();
    // One envelope, two destinations: amplitude AND filter brightness.
    patch.connect(env.out("env"), vca.in_("cv")).unwrap();
    patch.connect(env.out("env"), vcf.in_("cutoff")).unwrap();

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

    // A one-bar arpeggio: C3 - E3 - G3 - C4, played twice.
    let phrase = [48u8, 52, 55, 60, 48, 52, 55, 60];
    let note_seconds = 0.25;
    // Hold the gate for 80% of each slot so the release tail is audible
    // before the next note's attack (see the module doc above).
    let gate_high_seconds = note_seconds * 0.8;
    let gate_low_seconds = note_seconds - gate_high_seconds;

    println!("=== Render to WAV: Arpeggio Phrase ===\n");

    let mut left_all = Vec::new();
    let mut right_all = Vec::new();

    for (i, &note) in phrase.iter().enumerate() {
        let voct = midi_to_voct(note);
        println!("Step {}: MIDI {note} ({voct:.3}V)", i + 1);

        // Gate on: set the pitch and open the gate, then render the "on"
        // portion of this note's slot.
        pitch_cv.set(voct);
        gate_cv.set(5.0);
        let (l, r) = render(&mut patch, gate_high_seconds);
        left_all.extend(l);
        right_all.extend(r);

        // Gate off: release the envelope for the remainder of the slot.
        gate_cv.set(0.0);
        let (l, r) = render(&mut patch, gate_low_seconds);
        left_all.extend(l);
        right_all.extend(r);
    }

    let peak = left_all.iter().map(|s| s.abs()).fold(0.0_f64, f64::max);
    println!(
        "\nGenerated {} samples ({:.2}s)",
        left_all.len(),
        left_all.len() as f64 / sample_rate
    );
    println!("Peak amplitude: {peak:.2}V");

    // Quiver's Audio ports use a +-5V modular convention, but a .wav file's
    // samples are full-scale +-1.0, so scale down before writing (see the
    // `# Sample scale` note on `quiver::render`). We divide by 6 rather than
    // 5 to leave a little headroom for the resonant filter's brief overshoot
    // above 5V on sharp attack transients, so the WAV doesn't clip.
    let to_full_scale = |buf: &[f64]| -> Vec<f64> { buf.iter().map(|s| s / 6.0).collect() };
    let path = Path::new("target/render_wav.wav");
    write_wav(
        path,
        sample_rate as u32,
        &to_full_scale(&left_all),
        &to_full_scale(&right_all),
    )
    .expect("failed to write WAV file");

    println!(
        "\nWrote {} - play it in any audio player to hear Quiver make sound!",
        path.display()
    );
}

For the minimal patch-to-WAV workflow, see quick_taste (cargo run --example quick_taste), which writes target/quick_taste.wav.

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

Constructor & Sample-Rate Convention

GraphModule always provides set_sample_rate, and Patch::add calls it with the patch’s sample rate the moment a module is inserted. The graph is the single source of truth for sample rate — whatever value a module was built with is overwritten before its first tick. Given that, constructors follow one rule so a module’s sample-rate dependence is readable straight off its signature:

  • Sample-rate-dependent modules take sample_rate in new. Anything whose DSP needs the rate to initialize correctly-sized state — phase increments, delay/reverb buffers, envelope/filter coefficients — accepts it, e.g. Vco::new(sample_rate), Svf::new(sample_rate), DelayLine::new(sample_rate). The value seeds initial state; set_sample_rate stores the rate and (if the module caches coefficients) recomputes them so a later rate change stays correct.
  • Sample-rate-independent modules take new() (or only their value parameters). Gain, mixing, logic, and trigger/clock-driven modules do not need the rate: Vca::new(), StereoOutput::new(), Mixer::new(num_channels), Offset::new(offset). Their set_sample_rate is a no-op: fn set_sample_rate(&mut self, _: f64) {}.

Do not accept sample_rate “just in case”: an unused constructor parameter is misleading. MyDistortion below stores sample_rate because its DSP is sample-rate-dependent (an antialiasing/DC-blocking stage would use it) — a pure waveshaper that never reads the rate should take new() instead.

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. These are standalone helpers, not graph modules—you feed them samples from patch.tick() rather than patching them into the graph.

DOT/GraphViz Export

Generate visual diagrams of your patch:

use quiver::prelude::*;

let patch = /* your patch */;

// Export with the default (dark) style
let dot = DotExporter::export_default(&patch);
println!("{}", dot);

// Or pass an explicit style
let style = DotStyle::default();
let dot = DotExporter::export(&patch, &style);

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

DotStyle has preset constructors and a couple of builder methods:

// Presets: default() is a dark theme
let style = DotStyle::light();      // Light theme
let style = DotStyle::minimal();    // No port names, no signal colors

// Builders
let style = DotStyle::default()
    .with_rankdir("LR")             // LR, TB, BT, RL
    .with_node_shape("box");

// All fields are public for full control
let style = DotStyle {
    show_port_names: true,
    color_by_signal: true,          // Color-code edges by signal type
    ..DotStyle::default()
};

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. Scope::new takes the buffer size in samples; trigger settings are configured with setters:

let mut scope = Scope::new(1024);   // Buffer size in samples
scope.set_trigger_mode(TriggerMode::RisingEdge);
scope.set_trigger_level(0.0);

// In your audio loop
let (left, _right) = patch.tick();
scope.tick(left);

// Get waveform for display
let waveform = scope.buffer_vec();          // Vec<f64>
let points = scope.get_display_data();      // Vec<(x 0.0-1.0, voltage)>

Trigger modes:

  • Free: Continuous display
  • RisingEdge: Trigger on positive crossing of the trigger level
  • FallingEdge: Trigger on negative crossing
  • AnyEdge: Trigger on either crossing
  • Single: One-shot capture (buffer freezes after trigger)

Spectrum Analyzer

View frequency content. The constructor takes the FFT size (rounded up to a power of two) and the sample rate:

let mut analyzer = SpectrumAnalyzer::new(2048, 44100.0);
analyzer.set_smoothing(0.8);        // 0.0 = none, up to 0.99

// Feed samples; the spectrum recomputes each time the buffer fills
for sample in samples.iter() {
    analyzer.tick(*sample);
}

// Get (frequency_hz, magnitude_db) pairs
let spectrum = analyzer.get_spectrum();

// Query a specific frequency
let db_at_440 = analyzer.magnitude_at(440.0);

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

Level Meter

Monitor audio levels. All readings are in dB; peak hold defaults to 1.5 seconds and is adjusted with a setter:

let mut meter = LevelMeter::new(44100.0);
meter.set_peak_hold_time(0.5, 44100.0);  // 500ms peak hold

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

println!("RMS: {:.1} dB", meter.rms());
println!("Peak: {:.1} dB", meter.peak());
println!("Peak hold: {:.1} dB", meter.peak_hold());
if meter.is_clipping() {
    println!("Clipping!");
}

Automation Recording

Record parameter changes over time. The recorder samples parameter values at a configurable interval via a closure; times are in samples:

let mut recorder = AutomationRecorder::new(44100.0);
recorder.set_interval(441);              // Sample every 441 ticks (10ms)
recorder.add_track("filter_cutoff");
recorder.start();

// In your audio loop: the closure supplies the current value per track
for _ in 0..44100 {
    patch.tick();
    recorder.tick(|param_id| match param_id {
        "filter_cutoff" => Some(current_cutoff),
        _ => None,
    });
}

recorder.stop();

// Inspect or export
if let Some(track) = recorder.get_track("filter_cutoff") {
    println!("Duration: {:.2}s", track.duration_seconds());
    let value = track.value_at(22050);   // Interpolated value at sample 22050
}

let data = recorder.export();            // AutomationData (serde-serializable)
let json = serde_json::to_string(&data)?;

You can also build tracks by hand with AutomationTrack::new(param_id, sample_rate) and track.record(time_in_samples, value), then thin dense data with simplify(tolerance).

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 (freq, magnitude_db) in analyzer.get_spectrum() {
    draw_bar(freq, magnitude_db);
}

// For retained-mode GUIs
let path: Vec<(f64, f64)> = scope.get_display_data();
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-dsp/wasmCore WASM engine and AudioWorklet utilities
@quiver-dsp/reactReact hooks for UI integration
@quiver-dsp/typesTypeScript type definitions

Installation

npm install @quiver-dsp/wasm @quiver-dsp/react

Initializing the Engine

The WASM module must be initialized before use:

import { initWasm, createEngine } from '@quiver-dsp/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_module(typeId, name) takes the snake_case type_id first and a unique instance name second:

engine.add_module('vco', 'osc');
engine.add_module('vca', 'amp');
engine.add_module('stereo_output', 'out');

Connect Modules

Connections use "module.port" strings and return a stable CableId (a number):

const c1 = engine.connect('osc.saw', 'amp.in');
engine.connect('amp.out', 'out.left');
engine.connect('amp.out', 'out.right');

// Attenuated / modulated variants:
engine.connect_attenuated('lfo.sin', 'vcf.cutoff', 0.5);
engine.connect_modulated('lfo.sin', 'vcf.cutoff', 0.3, 5.0);

// Choose the output module, then compile.
engine.set_output('out');

Remove a cable by the CableId you kept:

engine.disconnect_cable(c1);

Compile the Graph

After adding or connecting modules, compile the graph:

engine.compile();

Processing Audio

Sample-by-Sample

// Process one sample; returns a Float64Array [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 Float32Array, interleaved: [L0, R0, L1, R1, ...]

In practice you rarely call process_block yourself — the AudioWorklet helper below runs the engine on the audio thread for you.

AudioWorklet Setup (Real-Time Audio)

Real-time audio runs the Quiver engine inside an AudioWorklet. createQuiverAudioNode takes an AudioContext and the worklet/wasm URLs, and returns a handle you drive with loadPatch, setParam, connect, MIDI methods, and so on. (The older createAudioContext helper has been removed — this worklet path is the only supported way to get audio out.)

import { createQuiverAudioNode } from '@quiver-dsp/wasm';
import workletUrl from '@quiver-dsp/wasm/dist/worklet.js?url';
import wasmUrl from '@quiver-dsp/wasm/quiver_bg.wasm?url';

async function startAudio(myPatch) {
  const ctx = new AudioContext();

  // The engine lives in the worklet render thread.
  const quiver = await createQuiverAudioNode(ctx, { workletUrl, wasmUrl });

  // Load a patch (a PatchDef object), then connect to the speakers.
  await quiver.loadPatch(myPatch);
  quiver.node.connect(ctx.destination);

  // Resume requires a user gesture.
  await ctx.resume();

  return { ctx, quiver };
}

createQuiverAudio({ workletUrl, wasmUrl }) is a convenience wrapper that creates the AudioContext and connects the node to the destination for you.

The returned handle exposes node, context, loadPatch, savePatch, setParam, addModule, removeModule, connect, disconnect, setOutput, addMidiInputs, midiNoteOn, midiNoteOff, midiCc, midiPitchBend, compile, reset, and dispose.

MIDI

Inject the engine-owned MIDI CV source modules, then drive them:

quiver.addMidiInputs();      // adds midi_voct, midi_gate, midi_velocity, midi_mod, midi_bend
quiver.midiNoteOn(60, 100);
quiver.midiCc(1, 64);        // CC1 drives midi_mod
quiver.midiPitchBend(0.5);

Cable those midi_* module outputs into your patch to play it from MIDI.

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-dsp/react';

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

  if (error) return <div>Error: {error.message}</div>;
  if (!isReady || !engine) return <div>Loading...</div>;

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

useQuiverParam

Bind a parameter to UI. Returns a [value, setValue] tuple:

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

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

  return <Knob value={value} onChange={setValue} />;
}

useQuiverLevel

Display level meters. Returns { rmsDb, peakDb }:

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

function Meter({ engine, nodeId, portId }) {
  const { rmsDb, peakDb } = useQuiverLevel(engine, nodeId, portId);

  return <LevelMeter rms={rmsDb} peak={peakDb} />;
}

Next Steps

Module Catalog

The WASM QuiverEngine exposes a searchable catalog of every registered module, with metadata for building dynamic “add module” UIs. All methods are on the engine returned by createEngine() (see Browser & App Integration).

Module identifiers are lowercase snake_case"vco", "svf", "adsr", "delay_line", "scale_quantizer" — matching each module’s Rust type_id().

Browsing Modules

Get the Full Catalog

const catalog = engine.get_catalog();
// CatalogResponse:
// {
//   modules: ModuleCatalogEntry[],
//   categories: string[],   // unique, sorted
// }

Each ModuleCatalogEntry looks like:

// {
//   type_id: "vco",
//   name: "VCO",
//   category: "Oscillators",
//   description: "Multi-waveform voltage-controlled oscillator",
//   keywords: ["oscillator", "vco", "saw", "square", "triangle"],
//   ports: { inputs: 5, outputs: 4, has_audio_in: false, has_audio_out: true },
//   tags: ["essential", "analog"]
// }

The catalog entry carries a port count summary (ports), not the full port list. Fetch detailed ports for a type with get_port_spec (below).

List Categories

const categories = engine.get_categories(); // string[], e.g. ["Oscillators", "Filters", ...]

Filter by Category

const oscillators = engine.get_modules_by_category('Oscillators');
const filters = engine.get_modules_by_category('Filters');

Searching Modules

Full-text search returns matching entries ranked by relevance:

const results = engine.search_modules('filter');
// ModuleCatalogEntry[], best matches first — e.g. svf, diode_ladder, parametric_eq

Matching considers the type_id, name, description, keywords, and category, so queries like "acid", "reverb", or "pitch" all work.

Detailed Port Information

For the concrete input/output ports of a module type, call get_port_spec with its type_id. This returns the module’s PortSpec ({ inputs, outputs }), where each port has id, name, and kind:

const spec = engine.get_port_spec('svf');
// {
//   inputs: [
//     { id: 0, name: "in",     kind: "Audio" },
//     { id: 1, name: "cutoff", kind: "CvUnipolar" },
//     { id: 2, name: "res",    kind: "CvUnipolar" },
//     ...
//   ],
//   outputs: [
//     { id: 10, name: "lp", kind: "Audio" },
//     { id: 11, name: "bp", kind: "Audio" },
//     { id: 12, name: "hp", kind: "Audio" },
//     { id: 13, name: "notch", kind: "Audio" },
//   ],
// }

Port kind is one of the signal types: Audio, CvBipolar, CvUnipolar, VoltPerOctave, Gate, Trigger, Clock.

Signal Colors

For cable visualization, the engine provides the default signal-type palette:

const colors = engine.get_signal_colors();
// {
//   audio: "#e94560",           // red
//   cv_bipolar: "#0f3460",      // dark blue
//   cv_unipolar: "#00b4d8",     // cyan
//   volt_per_octave: "#90be6d", // green
//   gate: "#f9c74f",            // yellow
//   trigger: "#f8961e",         // orange
//   clock: "#9d4edd",           // purple
// }

Port Compatibility

Check whether two signal kinds can be connected. Pass the kind strings (as found on a port spec), not port references:

const compat = engine.check_compatibility('CvBipolar', 'Audio');
// { status: "allowed" }
// or { status: "exact" }
// or { status: "warning", message: "..." }
statusMeaningUI hint
exactIdentical signal typeGreen cable
allowedDifferent but validNormal cable
warningWorks, but may clip or mismatchYellow cable + tooltip

Building a Module Browser UI

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

  const modules = useMemo(() => {
    if (query) return engine.search_modules(query);
    if (category) return engine.get_modules_by_category(category);
    return engine.get_catalog().modules;
  }, [engine, query, category]);

  const categories = useMemo(() => engine.get_categories(), [engine]);

  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>
        {categories.map((c) => (
          <option key={c} value={c}>{c}</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>
  );
}

Add a chosen module with engine.add_module(type_id, name) — for example engine.add_module('vco', 'osc1').

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-dsp/react package provides hooks for common patterns:

import {
  useQuiverLevel,
  useQuiverScope,
  useQuiverGate,
  useQuiverSpectrum
} from '@quiver-dsp/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.then(vcf).then(vca);
// Vco: () → f64
// Svf: f64 → f64
// Vca: f64 → f64
// Result: () → f64 ✓

// This won't compile: type mismatch
let bad = vco.then(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.then(id);

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

The Associativity Law

// These produce identical behavior:
let way1 = (vco.then(vcf)).then(vca);
let way2 = vco.then(vcf.then(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.then(b.then(c));
let v2 = a.then(b).then(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.then(stereo_module);
//        ^^^^^^^^^^^ f64
//                    ^^^^^^^^^^^^^^^^^ (f64, f64)

3. Compositionality

Build complex systems from simple parts:

// Each piece is simple
let voice = vco.then(vcf).then(vca);
let effects = delay.then(reverb);
let mixer = voice.fanout(effects).then(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 \]

In code the method is then (producing a Chain value):

let synth = vco.then(vcf).then(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 = reverb.fanout(delay);
// f64 → (f64, f64): one input feeds both processors

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]) \]

feedback takes a closure called as combine(input, previous_output), where previous_output is the module’s output delayed by one sample:

// 50% feedback
let echo = delay.feedback(|input, previous| input + previous * 0.5);

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).then(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
    .then(vcf)
    .then(vca)
    .then(Split::new())  // Mono to stereo
    .then(
        chorus_left.parallel(chorus_right)
    )
    .then(
        Merge::new(|l, r| (l + r) * 0.5)  // Back to mono, averaged
    );

Type Inference

Rust’s type inference works through combinators:

// Types are inferred
let synth = vco.then(vcf).then(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.then(vcf).then(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

// Statically chain a fixed rack of effects
let rack = distortion.then(chorus).then(delay).then(reverb);
// Each stage's Out type must match the next stage's In type

Pattern: Parallel Voices

fn parallel_voices<V: Module<In = f64, Out = f64>>(voices: [V; 4]) -> impl Module {
    let [v1, v2, v3, v4] = voices;
    // Note: parallel() pairs types, so this nests tuples:
    // In = (((f64, f64), f64), f64), and Out likewise
    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);

Rendering a Patch in Blocks

A compiled Patch can be advanced one sample at a time or a whole buffer at a time. tick_block fills caller-provided left/right slices, which is the shape audio callbacks want:

let mut patch = Patch::new(44100.0);
// ... add modules, connect, set_output, compile ...

let mut left = [0.0f64; 128];
let mut right = [0.0f64; 128];

// Fill an entire 128-sample buffer in one call.
patch.tick_block(&mut left, &mut right);

tick_block is equivalent to calling tick() in a loop, but keeps the per-buffer bookkeeping out of your code.

Zero-Allocation Guarantee

Once a patch is compiled, neither tick() nor tick_block() allocates. All buffers are pre-sized at compile() time, so the audio path never touches the allocator, never locks, and has bounded, predictable timing. This is enforced by tests/zero_alloc.rs, which asserts zero allocations across a block of ticks.

The corollary: do anything that allocates — add, connect, to_def, SamplePlayer::set_bufferbefore you start the audio thread, never during a callback.

Offline Rendering

With the std feature you can render a patch faster (or slower) than real time to a buffer or a WAV file:

use quiver::render::{render, render_to_wav};
use std::path::Path;

// Render 2 seconds of stereo audio into Vecs.
let (left, right) = render(&mut patch, 2.0);

// Or bounce straight to a 16-bit WAV file.
render_to_wav(&mut patch, 2.0, Path::new("bounce.wav"))?;

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-dsp = { version = "0.2", 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±5VPitch (0V = C4)
fmBipolar CV±5VExponential FM (±5V ≈ ±5 octaves)
pwUnipolar CV0-10VPulse width, default 50%
syncGate0/5VHard sync reset
fm_linBipolar CV±5VLinear through-zero FM (±5V ≈ ±100% of base freq)

Outputs

PortSignalDescription
sinAudioSine wave
triAudioTriangle wave (bandlimited, PolyBLAMP)
sawAudioSawtooth wave (bandlimited, PolyBLEP)
sqrAudioSquare/pulse wave (bandlimited, PolyBLEP)

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 with a CV-controllable stereo second channel.

let noise = patch.add("noise", NoiseGenerator::new());

Inputs

PortSignalRangeDescription
correlationUnipolar CV0-10VStereo correlation (0 = independent, 1 = identical), default 0.3

Outputs

PortSignalDescription
whiteAudioWhite noise
pinkAudioPink noise
white2AudioSecond white channel, correlated with white
pink2AudioSecond pink channel, correlated with pink

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.


Supersaw

JP-8000-style stack of seven detuned PolyBLEP saws with an octave-down sub. type_id: supersaw.

let saw = patch.add("saw", Supersaw::new(44100.0));

Inputs

PortSignalRangeDescription
voctV/Oct±5VPitch (0V = C4)
detuneUnipolar CV0-10VDetune spread of the 7 voices, default 50%
mixUnipolar CV0-10VBlend between center voice and full supersaw, default 50%

Outputs

PortSignalDescription
outAudioMixed 7-oscillator supersaw
subAudioOctave-down bandlimited saw sub-oscillator

Wavetable

Mip-mapped, bandlimited wavetable oscillator with 8 tables and smooth crossfade morphing. type_id: wavetable.

let wt = patch.add("wt", Wavetable::new(44100.0));

Inputs

PortSignalRangeDescription
v_octV/Oct±5VPitch (0V = C4)
tableUnipolar CV0-10VTable select across 8 tables
morphUnipolar CV0-10VCrossfade morph between adjacent tables
syncGate0/5VHard sync (resets phase)

Outputs

PortSignalDescription
outAudioWavetable output

The 8 built-in tables are: Sine, Triangle, Saw, Square, Pulse (25%), Pulse (12%), Formant A, Formant O.


FormantOsc

Vocal-synthesis oscillator: a glottal pulse driven through five parallel resonant formant filters. type_id: formant_osc.

let vox = patch.add("vox", FormantOsc::new(44100.0));

Inputs

PortSignalRangeDescription
v_octV/Oct±5VPitch (0V = C4)
vowelUnipolar CV0-10VInterpolates the vowel A → E → I → O → U
formant_shiftBipolar CV±5VShifts all formant frequencies (0.5×–2×)
vibratoUnipolar CV0-10VVibrato depth (up to ±0.5 semitone)

Outputs

PortSignalDescription
outAudioVocal formant output

KarplusStrong

Karplus-Strong physical-model plucked string with damping, brightness, and inharmonicity. type_id: karplus_strong.

let string = patch.add("string", KarplusStrong::new(44100.0));

Inputs

PortSignalRangeDescription
voctV/Oct±5VPitch → string period
triggerTrigger0/5VRising edge plucks the string
dampingUnipolar CV0-10VLoop lowpass amount, default 50%
brightnessUnipolar CV0-10VNoise-vs-impulse excitation blend, default 50%
stretchBipolar CV±5VAll-pass stretch / inharmonicity

Outputs

PortSignalDescription
outAudioPlucked-string output

SamplePlayer

Mono sample playback with V/Oct pitch, selectable start position, one-shot / looping modes, and an end-of-sample trigger. Reads are cubic-interpolated; the audio path is allocation-free (only set_buffer allocates). type_id: sample_player.

// buffer: Vec<f64>, recorded at buffer_sample_rate; engine runs at engine_sample_rate.
let sp = patch.add("sp", SamplePlayer::new(buffer, 44100.0, 44100.0));
// Or start empty and load later with `set_buffer`:
let sp = patch.add("sp", SamplePlayer::empty(44100.0));

Inputs

PortSignalRangeDescription
trigTrigger0/5VRising edge starts one-shot playback
gateGate0/5VGated playback (release stops it)
voctV/Oct±5VPlayback pitch (0V = unity rate)
startUnipolar CV0-10VStart position (0–1 of buffer)
loopGate0/5VEnables looping when high

Outputs

PortSignalDescription
outAudioSample output
eosTriggerFires at end of sample

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 TPT / zero-delay-feedback (ZDF) filter with four simultaneous outputs and stable self-oscillation. type_id: svf.

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

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
cutoffUnipolar CV0-10VCutoff frequency (20 Hz–20 kHz, exponential)
resUnipolar CV0-10VResonance (0–1)
fmBipolar CV±5VLinear FM added to cutoff
keytrackV/Oct±5VKeyboard tracking pitch
keytrack_amtUnipolar CV0-10VKeyboard tracking amount (0–1)

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

Resonance sets the ZDF damping factor k = 2 − 2·res: res = 0 gives k = 2 (Q ≈ 0.5), and res → 1 drives k → 0 (near-infinite Q). Integrator states are soft-clipped, so high resonance self-oscillates as a slow-decay sine ring at the cutoff frequency rather than blowing up.

ResonanceCharacter
0.0Flat response
0.5Slight peak
0.9Prominent peak
1.0Self-oscillation (slow-decay ring)

DiodeLadderFilter

Classic 24dB/octave (4-pole) TB-303/Moog-style ladder filter with diode saturation. type_id: diode_ladder.

let ladder = patch.add("filter", DiodeLadderFilter::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
cutoffUnipolar CV0-10VCutoff frequency (20 Hz–20 kHz, exponential)
resUnipolar CV0-10VResonance (0–1; feedback k = res·4)
fmBipolar CV±5VLinear FM added to cutoff
keytrackV/Oct±5VKeyboard tracking pitch
keytrack_amtUnipolar CV0-10VKeyboard tracking amount (0–1)
driveUnipolar CV0-10VInput drive (gain 1×–4×)

Outputs

PortSignalDescription
outAudio24 dB/oct main output
pole1Audio6 dB/oct tap
pole2Audio12 dB/oct tap
pole3Audio18 dB/oct tap

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

ParametricEq

Three-band equalizer — low shelf, parametric mid (with Q), high shelf — using cached biquads. Each band spans ±12 dB. type_id: parametric_eq.

let eq = patch.add("eq", ParametricEq::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
low_gainBipolar CV±5VLow-shelf gain (±12 dB)
low_freqUnipolar CV0-10VLow-shelf frequency (50–500 Hz)
mid_gainBipolar CV±5VMid peaking gain (±12 dB)
mid_freqUnipolar CV0-10VMid frequency (200 Hz–8 kHz)
mid_qUnipolar CV0-10VMid Q (0.5–10)
high_gainBipolar CV±5VHigh-shelf gain (±12 dB)
high_freqUnipolar CV0-10VHigh-shelf frequency (2–12 kHz)

Outputs

PortSignalDescription
outAudioEqualized output

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_("keytrack"))?;
// Set the amount (0-1) via the `keytrack_amt` input; 1.0 = 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/5VGate on/off
retrigTrigger0/5VRetrigger (restarts attack from the current level)
attackUnipolar CV0-10VAttack time, default 0.1
decayUnipolar CV0-10VDecay time (true segment duration), default 0.3
sustainUnipolar CV0-10VSustain level, default 0.7
releaseUnipolar CV0-10VRelease time (true segment duration), default 0.4
shapeGate0/5V0V = linear ramps, high = exponential one-pole

Outputs

PortSignalDescription
envUnipolar CVEnvelope output (0-10V)
invUnipolar CVInverted envelope
eocTriggerEnd-of-cycle trigger

decay and release are true segment durations — the envelope traverses its span in the set time regardless of the sustain level. The shape input toggles between linear and exponential curves.

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

Envelope Follower

Extracts the amplitude envelope of an audio signal, with adjustable attack/release ballistics. type_id: envelope_follower.

let follower = patch.add("follow", EnvelopeFollower::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
attackUnipolar CV0-10VDetector attack time, default 0.2
releaseUnipolar CV0-10VDetector release time, default 0.3
gainUnipolar CV0-10VOutput gain (×4), default 0.5

Outputs

PortSignalDescription
outUnipolar CVAmplitude envelope (0-10V)
invUnipolar CVInverted envelope

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"), 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 a V/Oct input to the nearest degree of a fixed scale. The scale is chosen at construction (not a port). type_id: quantizer.

let quant = patch.add("quant", Quantizer::major());
// Also: Quantizer::new(Scale::Dorian), Quantizer::chromatic(), Quantizer::minor()

Input

PortSignalDescription
inV/OctUnquantized pitch

Output

PortSignalDescription
outV/OctQuantized pitch

Available Scales

Scale: Chromatic, Major, Minor, PentatonicMajor, PentatonicMinor, Dorian, Mixolydian, Blues. Change at runtime with quantizer.set_scale(Scale::Minor).


Scale Quantizer

A quantizer with CV-selectable root and scale, boundary hysteresis, a note-change trigger, and optional microtuning. type_id: scale_quantizer.

let sq = patch.add("sq", ScaleQuantizer::new(44100.0));

Inputs

PortSignalRangeDescription
inV/Oct±5VPitch to quantize
rootUnipolar CV0-10VRoot note (0–11 semitones)
scaleUnipolar CV0-10VScale select (7 built-in scales)

Outputs

PortSignalDescription
outV/OctQuantized pitch
triggerTriggerFires on a committed note change

Microtuning (with the alloc feature)

// Install a custom scale from cents offsets within an octave:
sq_module.set_custom_scale(&[0.0, 200.0, 350.0, 700.0, 900.0]);

// Or load a Scala .scl file body:
sq_module.load_scala(scl_source)?;

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.

Dynamics

Dynamics processors shape a signal’s level over time: compressing peaks, limiting brick-wall ceilings, gating noise, and ducking one signal under another.

The EnvelopeFollower (an amplitude detector) is documented under Envelopes & Modulators.

Compressor

Feed-forward compressor with dB-domain gain computation, makeup gain, and an internally normalled external sidechain. type_id: compressor.

let comp = patch.add("comp", Compressor::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
thresholdUnipolar CV0-10VThreshold (×5 V), default 0.5
ratioUnipolar CV0-10VRatio (1:1 – 20:1), default 0.5
attackUnipolar CV0-10VAttack time, default 0.2
releaseUnipolar CV0-10VRelease time, default 0.3
makeupUnipolar CV0-10VMakeup gain (1×–4×), default 0.0
sidechainAudio±5VExternal key; normalled to in when unpatched

Outputs

PortSignalDescription
outAudioCompressed output
grUnipolar CVGain-reduction CV

Limiter

True brick-wall limiter with soft (renormalized tanh) or hard knee, plus an internally normalled sidechain key. type_id: limiter.

let lim = patch.add("lim", Limiter::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
thresholdUnipolar CV0-10VCeiling (×5 V), default 0.8
releaseUnipolar CV0-10VRelease time, default 0.3
softGate0/5VSoft (tanh) knee vs hard limiting; default on
sidechainAudio±5VExternal key; normalled to in when unpatched

Outputs

PortSignalDescription
outAudioLimited output (hard-clamped to ±threshold)
grUnipolar CVGain reduction

The output is always hard-clamped to ±threshold, so peaks never exceed the ceiling regardless of knee shape.


Noise Gate

Downward noise gate with hysteresis, a hold time, and an anti-click fade, plus an internally normalled sidechain key. type_id: noise_gate.

let gate = patch.add("gate", NoiseGate::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
thresholdUnipolar CV0-10VOpen threshold (×5 V; close = 0.7×), default 0.1
attackUnipolar CV0-10VDetector attack, default 0.1
releaseUnipolar CV0-10VDetector release, default 0.3
rangeUnipolar CV0-10VMaximum attenuation depth, default 1.0
sidechainAudio±5VExternal key; normalled to in when unpatched

Outputs

PortSignalDescription
outAudioGated output
gateGateGate state (high when open)

Ducker

Dedicated sidechain ducking: the key input attenuates the main signal by up to amount. Knob values combine with CV through ModulatedParam. type_id: ducker.

let duck = patch.add("duck", Ducker::new(44100.0));
patch.connect(kick.out("out"), duck.in_("key"))?; // kick ducks the pad
patch.connect(pad.out("out"), duck.in_("in"))?;

Inputs

PortSignalRangeDescription
inAudio±5VMain signal
keyAudio±5VSidechain key that drives the ducking
amountBipolar CV±5VDuck depth CV (summed with the knob)
thresholdBipolar CV±5VThreshold CV (summed with the knob)
attackUnipolar CV0-10VEnvelope attack, default 0.1
releaseUnipolar CV0-10VEnvelope release, default 0.3

Outputs

PortSignalDescription
outAudioDucked output
grUnipolar CVGain reduction

Unlike the compressor/limiter/gate sidechains (which are normalled to the main input), the Ducker’s key is a dedicated, always-separate input. Knob values are also settable in code with set_amount, amount, set_threshold, threshold.

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

Mid/Side Encode

Encodes an L/R stereo pair to mid/side. type_id: mid_side_encode.

let ms = patch.add("ms", MidSideEncode::new());

Inputs

PortSignalDescription
leftAudioLeft channel
rightAudioRight channel

Outputs

PortSignalDescription
midAudio(L + R) / 2
sideAudio(L − R) / 2

Mid/Side Decode

Decodes mid/side back to L/R with an adjustable stereo width; at width 1.0 it exactly inverts MidSideEncode. type_id: mid_side_decode.

let ms = patch.add("ms", MidSideDecode::new());

Inputs

PortSignalRangeDescription
midAudio±5VMid channel
sideAudio±5VSide channel
widthUnipolar CV0-10VStereo width (0 = mono, 1 = identity, 2 = doubled), default 1.0

Outputs

PortSignalDescription
leftAudioM + S·width
rightAudioM − S·width

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\( \vert x \vert \)
half_posPositive half only\( \max(x, 0) \)
half_negNegative half only\( \min(x, 0) \)
absAbsolute value\( \vert x \vert \)
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

Sequencing

Arpeggiator

Captures held notes on gate edges and replays them across selectable octaves and patterns on each clock pulse. type_id: arpeggiator.

let arp = patch.add("arp", Arpeggiator::new(44100.0));

Inputs

PortSignalDescription
v_octV/OctInput note to capture
gateGateCaptures/releases the note on rising/falling edge
clockClockAdvances the sequence
patternUnipolar CVPattern select (Up / Down / UpDown / Random)
octavesUnipolar CVOctave range (1–4)
resetGateResets the sequence and clears held notes

Outputs

PortSignalDescription
v_oct_outV/OctArpeggiated pitch
gate_outGateGate output (follows the clock)
triggerTriggerPulse on each step

Chord Memory

Generates four V/Oct voices from a root note across nine chord types, with inversion and octave spread. type_id: chord_memory.

let chord = patch.add("chord", ChordMemory::new());

Inputs

PortSignalDescription
rootV/OctRoot note of the chord
chordUnipolar CVChord-type select (9 types)
inversionUnipolar CVInversion (rotates the bass note)
spreadUnipolar CVSpreads voices across octaves

Outputs

PortSignalDescription
voice1V/OctChord voice 1
voice2V/OctChord voice 2
voice3V/OctChord voice 3
voice4V/OctChord voice 4

Chord types: Major, Minor, Seventh, MajorSeventh, MinorSeventh, Diminished, Augmented, Sus2, Sus4.


Euclidean

Euclidean rhythm generator: evenly distributes a pulse count across a step count, with rotation and a per-cycle accent. type_id: euclidean.

let euclid = patch.add("euclid", Euclidean::new(44100.0));

Inputs

PortSignalDescription
clockTriggerAdvances the pattern on rising edge
stepsUnipolar CVStep count (2–16), default 0.5
pulsesUnipolar CVPulse (fill) count, default 0.25
rotationUnipolar CVRotates the pattern
resetTriggerResets the step counter

Outputs

PortSignalDescription
outTriggerPulse output for active steps
accentTriggerAccent on the first pulse of each cycle

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 by reflecting the signal about a threshold. Supports opt-in oversampling via set_oversample. type_id: wavefolder.

let folder = patch.add("folder", Wavefolder::new(1.0)); // threshold

Inputs

PortSignalDescription
inAudioInput signal
thresholdUnipolar CVFold threshold (default = constructor value)

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.


Signal Monitoring (Scope, Spectrum Analyzer, Level Meter)

Scope, SpectrumAnalyzer, and LevelMeter are standalone visual tools, not graph modules—they cannot be added to a patch with patch.add(...). Instead, feed them samples from patch.tick():

let mut scope = Scope::new(1024);
let mut meter = LevelMeter::new(44100.0);

let (left, _right) = patch.tick();
scope.tick(left);
meter.tick(left);

See Visualize Your Patch for the full API.


Distortion

Waveshaping distortion with four selectable algorithms (soft clip, hard clip, foldback, asymmetric), a one-pole tone control, dry/wet mix, and opt-in oversampling (set_oversample). type_id: distortion.

let dist = patch.add("dist", Distortion::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
driveUnipolar CV0-10VDrive into the shaper, default 0.5
toneUnipolar CV0-10VTone (one-pole lowpass), default 0.5
modeUnipolar CV0-10VAlgorithm select (4 modes)
mixUnipolar CV0-10VDry/wet, default fully wet

Output

PortSignalDescription
outAudioDistorted output

Bitcrusher

Lo-fi bit-depth and sample-rate reduction. type_id: bitcrusher.

let crush = patch.add("crush", Bitcrusher::new());

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
bitsUnipolar CV0-10VBit-depth reduction (~1–16 bits), default 0.5
downsampleUnipolar CV0-10VSample-rate reduction

Output

PortSignalDescription
outAudioCrushed output

Delay Line

Delay of up to 2 seconds with feedback and wet/dry mix; slew-smoothed delay time for CV-modulated effects. Breaks feedback cycles. type_id: delay_line.

let delay = patch.add("delay", DelayLine::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
timeUnipolar CV0-10VDelay time (1 ms–2 s, exponential), default 0.5
feedbackUnipolar CV0-10VFeedback (0–0.99)
mixUnipolar CV0-10VDry/wet, default 0.5

Output

PortSignalDescription
outAudioMixed dry + delayed output

Chorus

Three-voice modulated-delay chorus with a mono and a stereo-spread output. type_id: chorus.

let chorus = patch.add("chorus", Chorus::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
rateUnipolar CV0-10VLFO rate (0.1–5 Hz), default 0.3
depthUnipolar CV0-10VModulation depth (0–25 ms), default 0.5
mixUnipolar CV0-10VDry/wet, default 0.5

Outputs

PortSignalDescription
outAudioMono mixed output
leftAudioLeft stereo-spread output
rightAudioRight stereo-spread output

Flanger

Short-modulated-delay flanger with feedback; mono in, stereo out via a spread control. out mirrors left for backward compatibility. type_id: flanger.

let flanger = patch.add("flanger", Flanger::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
rateUnipolar CV0-10VLFO rate, default 0.3
depthUnipolar CV0-10VSweep depth, default 0.5
feedbackBipolar CV±5VFeedback (−0.95–0.95)
mixUnipolar CV0-10VDry/wet, default 0.5
spreadUnipolar CV0-10VStereo L/R decorrelation (0 = mono, 1 = 180°), default 0.5

Outputs

PortSignalDescription
outAudioLegacy mono output (mirrors left)
leftAudioLeft channel
rightAudioRight channel (phase-offset sweep)

Phaser

Cascaded-allpass phaser (2/4/6 selectable stages) with feedback; mono in, stereo out with a spread control. out mirrors left. type_id: phaser.

let phaser = patch.add("phaser", Phaser::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
rateUnipolar CV0-10VLFO rate, default 0.3
depthUnipolar CV0-10VNotch sweep depth, default 0.7
feedbackBipolar CV±5VFeedback (−0.95–0.95)
mixUnipolar CV0-10VDry/wet, default 0.5
stagesUnipolar CV0-10VAllpass stage count (<0.33 → 2, <0.66 → 4, else 6)
spreadUnipolar CV0-10VStereo L/R decorrelation, default 0.5

Outputs

PortSignalDescription
outAudioLegacy mono output (mirrors left)
leftAudioLeft channel
rightAudioRight channel (phase-offset notch sweep)

Tremolo

Amplitude-modulation tremolo with a sine-to-triangle shape blend. type_id: tremolo.

let trem = patch.add("trem", Tremolo::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
rateUnipolar CV0-10VLFO rate (0.1–20 Hz), default 0.3
depthUnipolar CV0-10VModulation depth, default 0.5
shapeUnipolar CV0-10VLFO shape blend (sine ↔ triangle)

Output

PortSignalDescription
outAudioAmplitude-modulated output

Vibrato

Pitch-modulation vibrato via a modulated delay line; defaults fully wet. type_id: vibrato.

let vib = patch.add("vib", Vibrato::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
rateUnipolar CV0-10VLFO rate (0.1–15 Hz), default 0.3
depthUnipolar CV0-10VPitch-modulation depth, default 0.5
mixUnipolar CV0-10VDry/wet, default fully wet

Output

PortSignalDescription
outAudioPitch-modulated output

Reverb

Freeverb-style algorithmic reverb (8 comb + 4 allpass) with size, damping, mix, and pre-delay. Stereo output. type_id: reverb.

let reverb = patch.add("reverb", Reverb::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
sizeUnipolar CV0-10VRoom size / decay, default 0.5
dampingUnipolar CV0-10VHigh-frequency damping, default 0.5
mixUnipolar CV0-10VDry/wet, default 0.5
predelayUnipolar CV0-10VPre-delay (0–100 ms)

Outputs

PortSignalDescription
leftAudioLeft reverb channel
rightAudioRight reverb channel

Pitch Shifter

Granular (two-grain, crossfaded) real-time pitch shifter. type_id: pitch_shifter.

let shift = patch.add("shift", PitchShifter::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio input
shiftBipolar CV±5VPitch shift (±24 semitones)
windowUnipolar CV0-10VGrain window (10–100 ms), default 0.5
mixUnipolar CV0-10VDry/wet, default fully wet

Output

PortSignalDescription
outAudioPitch-shifted output

Granular

Granular processor: records the input into a circular buffer and plays overlapping Hann-windowed grains. type_id: granular.

let gran = patch.add("gran", Granular::new(44100.0));

Inputs

PortSignalRangeDescription
inAudio±5VAudio recorded into the buffer
positionUnipolar CV0-10VPlayback position, default 0.5
sizeUnipolar CV0-10VGrain size (10–500 ms), default 0.3
densityUnipolar CV0-10VGrains per second (1–20), default 0.5
pitchBipolar CV±5VPitch shift (±24 semitones)
sprayUnipolar CV0-10VPosition randomization, default 0.1
freezeGate0/5VStops recording while high

Output

PortSignalDescription
outAudioGranular output

Vocoder

Channel vocoder: per-band envelope followers on the modulator impose its spectral envelope onto the carrier. type_id: vocoder.

let voc = patch.add("voc", Vocoder::new(44100.0));

Inputs

PortSignalRangeDescription
carrierAudio±5VCarrier (typically an oscillator)
modulatorAudio±5VModulator (typically voice)
bandsUnipolar CV0-10VBand count (4–16), default 1.0
attackUnipolar CV0-10VEnvelope-follower attack, default 0.3
releaseUnipolar CV0-10VEnvelope-follower release, default 0.3

Output

PortSignalDescription
outAudioVocoded output

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.). The module holds an Arc<AtomicF64>; any thread can set() the value and the audio thread reads the latest value each tick.

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
::audio(arc)AudioExternal audio sample feed

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. Feed it raw 3-byte MIDI messages with handle_message; it maintains a set of atomic values (Arc<AtomicF64> fields) that plug straight into ExternalInput modules.

let mut midi = MidiState::new();

// In your MIDI callback: pass raw MIDI bytes
midi.handle_message(&[0x90, 60, 100]);  // Note on: note 60, velocity 100
midi.handle_message(&[0x80, 60, 0]);    // Note off
midi.handle_message(&[0xB0, 1, 64]);    // CC1 (mod wheel) = 64
midi.handle_message(&[0xE0, 0x00, 0x40]); // Pitch bend (center)

// Read current state (atomic fields, safe from the audio thread)
let voct = midi.pitch.get();        // V/Oct of current note
let gate = midi.gate.get();         // Gate state (0 or 5V)
let velocity = midi.velocity.get(); // 0-10V
let mod_wheel = midi.mod_wheel.get();

// Coherent, torn-free (pitch, gate) pair from the same note event
let (pitch, gate) = midi.note_snapshot();

Bridge the state into a patch by cloning its atomic fields into ExternalInput modules:

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

Other fields: pitch_bend, aftertouch, sustain, expression. Held-note queries: held_notes(), notes_active(). Housekeeping: reset(), all_notes_off().


OSC Integration

Quiver’s OSC support is transport-agnostic: you receive OSC packets with any network library, parse them into OscMessage values, and Quiver routes them to Arc<AtomicF64> values shared with the patch.

OscInput

A graph module that emits the current value of an Arc<AtomicF64> updated by OSC. Constructed with the OSC address (for documentation), the shared value, and the output signal kind.

let cutoff = Arc::new(AtomicF64::new(5.0));
let osc_in = patch.add(
    "cutoff_osc",
    OscInput::new("/synth/cutoff", Arc::clone(&cutoff), SignalKind::CvUnipolar),
);
patch.connect(osc_in.out("out"), vcf.in_("cutoff"))?;

OscBinding

Maps an OSC address pattern to a shared value, with optional scale and offset applied to the message’s first float argument.

let cutoff = Arc::new(AtomicF64::new(0.0));
let binding = OscBinding::new("/synth/cutoff", Arc::clone(&cutoff))
    .with_scale(10.0)   // map incoming 0-1 to 0-10V
    .with_offset(0.0);

// When a message arrives (returns true if the pattern matched)
let msg = OscMessage::new("/synth/cutoff").with_float(0.5);
binding.apply(&msg);    // cutoff is now 5.0

OscReceiver

Routes incoming OscMessages to a set of bindings. It does not open a network socket—feed it messages from whatever transport you use.

let mut receiver = OscReceiver::new();
receiver.bind("/synth/cutoff", Arc::clone(&cutoff));
receiver.bind_scaled("/synth/resonance", Arc::clone(&resonance), 1.0, 0.0);

// In your control thread, after parsing a packet into an OscMessage
if receiver.handle_message(&msg) {
    // At least one binding matched
}

// Diagnostics
let total = receiver.message_count();
let matched = receiver.matched_count();

OscPattern

Pattern matching for OSC addresses. * matches within a single path component, [a-c] matches character classes, {a,b} matches alternatives.

let pattern = OscPattern::new("/synth/voice/*/cutoff");

// Matches:
// /synth/voice/1/cutoff
// /synth/voice/2/cutoff
// etc.

if pattern.matches(&msg.address) {
    // Handle message
}

Web Audio

WebAudioConfig

Configuration shared by the Web Audio types:

let config = WebAudioConfig {
    input_channels: 0,
    output_channels: 2,
    sample_rate: 44100.0,
    block_size: 128,   // Web Audio render quantum
};

WebAudioProcessor

A trait for Web Audio-compatible processors—implement it on your own type to adapt it for AudioWorklet use:

impl WebAudioProcessor for MySynth {
    fn initialize(&mut self, config: &WebAudioConfig) { /* ... */ }
    fn process(&mut self, inputs: &[f32], outputs: &mut [f32]) -> bool {
        // Fill `outputs` with interleaved samples; return true to keep running
        true
    }
    fn set_parameter(&mut self, name: &str, value: f64) { /* ... */ }
    fn get_parameter(&self, name: &str) -> Option<f64> { None }
    fn parameter_names(&self) -> Vec<String> { vec![] }
}

WebAudioBlockProcessor

Handles the 128-sample render quantum with pre-allocated buffers. Drive it with a closure that produces one stereo frame per call—typically patch.tick():

let mut processor = WebAudioBlockProcessor::new();  // or ::with_config(config)
processor.activate();

// Each render quantum: returns interleaved f32 samples
let interleaved = processor.process_with(|_i| patch.tick());

Parameters registered with add_parameter(name, initial) return an Arc<AtomicF64> you can share with ExternalInput modules in the patch.

WebAudioWorklet

A lightweight adapter holding configuration and a parameter map:

let mut worklet = WebAudioWorklet::new();
let cutoff = worklet.add_parameter("cutoff", 5.0);
worklet.initialize(WebAudioConfig::default());

worklet.set_parameter("cutoff", 7.5);

Interleaving

Web Audio uses interleaved f32 stereo; Quiver processes f64 channels. The conversion helpers write into caller-provided buffers (no allocation):

// Separate f64 channels -> interleaved f32
let mut interleaved = vec![0.0f32; left.len() * 2];
interleave_stereo(&left, &right, &mut interleaved);

// Interleaved f32 -> separate f64 channels
let mut left = vec![0.0f64; input.len() / 2];
let mut right = vec![0.0f64; input.len() / 2];
deinterleave_stereo(&input, &mut left, &mut right);

f64_to_f32_block and f32_to_f64_block convert single channels in place.


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

Or let MidiState do the message parsing and share its atomic fields with the patch as shown above.

OSC-Controlled Parameters

let cutoff_cv = Arc::new(AtomicF64::new(5.0));
let reso_cv = Arc::new(AtomicF64::new(0.5));
let attack_cv = Arc::new(AtomicF64::new(0.01));

// Modules in the patch read these values
let cutoff_in = patch.add(
    "cutoff",
    OscInput::new("/filter/cutoff", cutoff_cv.clone(), SignalKind::CvUnipolar),
);

// Control thread routes messages
let mut receiver = OscReceiver::new();
receiver.bind_scaled("/filter/cutoff", cutoff_cv.clone(), 10.0, 0.0);
receiver.bind("/filter/reso", reso_cv.clone());
receiver.bind("/env/attack", attack_cv.clone());

// In OSC handler
receiver.handle_message(&msg);

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
}

Defining Ports

Ports are declared with PortDef::new(id, name, kind) plus chainable builders:

PortDef::new(0, "in", SignalKind::Audio)
PortDef::new(1, "cutoff", SignalKind::CvUnipolar)
    .with_default(5.0)       // Value when unpatched (default 0.0)
    .with_attenuverter()     // Flag an attenuverter control for UIs
PortDef::new(2, "right", SignalKind::Audio)
    .normalled_to(1)         // Falls back to port 1 when unpatched
BuilderEffect
.with_default(v)Sets the value used when no cable is connected
.with_attenuverter()Marks the input as having an attenuverter
.normalled_to(port_id)Internal fallback source when unpatched

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

// Scale the signal (0.0-1.0)
patch.connect_attenuated(from, to, 0.5)?;

// Full modulation controls: attenuverter (-2.0 to 2.0) + DC offset (±10V)
patch.connect_modulated(from, to, 0.5, 2.0)?;

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:

// Port 1 ("right") falls back to port 0 ("left") when unpatched
PortDef::new(1, "right", SignalKind::Audio).normalled_to(0)

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 (`list` / `by_category` are associated functions)
for preset in PresetLibrary::list() {
    println!("{}: {}", preset.name, preset.description);
}

// Get by category
let basses = PresetLibrary::by_category(PresetCategory::Bass);

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

// Build a preset (`get` returns Option, `build` returns Result)
if let Some(preset) = library.get("Moog Bass") {
    let patch = preset.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