Quiver: Modular Audio Synthesis
“A quiver is a directed graph — nodes connected by arrows. In audio, our nodes are modules, our arrows are patch cables, and signal flows through their composition.”
Quiver is a Rust library for building modular audio synthesis systems. It combines the mathematical elegance of category theory with the tactile joy of patching a hardware modular synthesizer.
flowchart LR
subgraph "Oscillator"
VCO[🎵 VCO]
end
subgraph "Filter"
VCF[📊 VCF]
end
subgraph "Amplifier"
VCA[🔊 VCA]
end
subgraph "Envelope"
ADSR[📈 ADSR]
end
VCO -->|saw| VCF
VCF -->|lowpass| VCA
ADSR -->|env| VCF
ADSR -->|env| VCA
VCA --> Output[🔈]
Why Quiver?
Type-Safe Patching
Quiver catches connection errors at compile time. Connect a gate to a V/Oct input? The type system prevents it before you hear a single pop.
Hardware-Inspired Semantics
Voltages follow real modular conventions:
- ±5V for audio signals
- 1V/octave for pitch (0V = C4)
- 0-5V for gates and triggers
- 0-10V for unipolar CV
Mathematical Foundations
Built on Arrow-style functional combinators, Quiver lets you compose DSP operations like mathematical functions:
$$f \ggg g = g \circ f$$
Chain two modules and their types compose automatically.
Three-Layer Architecture
graph TB
subgraph "Layer 3: Patch Graph"
G[Runtime Topology]
end
subgraph "Layer 2: Port System"
P[Signal Conventions]
end
subgraph "Layer 1: Typed Combinators"
C[Arrow Composition]
end
C --> P --> G
style C fill:#4a9eff,color:#fff
style P fill:#f9a826,color:#fff
style G fill:#50c878,color:#fff
- Layer 1 — Compile-time type checking with zero-cost abstractions
- Layer 2 — Hardware-inspired signal conventions
- 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()
);
}
What You’ll Learn
This documentation guides you from first patch to advanced synthesis:
- Getting Started — Install and build your first sound
- Tutorials — Progressive lessons in synthesis
- How-To Guides — Task-focused recipes
- Concepts — Deep dives into theory
- Reference — Complete module documentation
The Name
In category theory, a quiver is a directed graph: objects connected by morphisms. In our world:
| Category Theory | Quiver Audio |
|---|---|
| Objects | Modules |
| Morphisms (Arrows) | Patch Cables |
| Composition | Signal Flow |
| Identity | Pass-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.1"
Or with specific features:
[dependencies]
quiver-dsp = { version = "0.1", features = ["simd"] }
To track the development branch instead, use a git dependency:
[dependencies]
quiver-dsp = { git = "https://github.com/alexnodeland/quiver" }
Available Features
| Feature | Default | Description |
|---|---|---|
std | Yes | Full functionality including OSC, visualization (implies alloc) |
alloc | No | Serialization, presets, and I/O for no_std + heap environments |
simd | No | SIMD vectorization for block processing (works with any tier) |
wasm | No | WebAssembly 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.1", 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.1", 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.1"
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
| Tier | DSP | Serialize | Presets | I/O | OSC | Visual | MDK |
|---|---|---|---|---|---|---|---|
| Core | ✓ | ||||||
alloc | ✓ | ✓ | ✓ | ✓ | |||
std | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Implementation Notes
- Uses
BTreeMapinstead ofHashMapin 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
alloccrate (Vec, Box, String)
Verifying Installation
Create a simple test program:
use quiver::prelude::*;
fn main() {
let patch = Patch::new(44100.0);
println!("Quiver is working! Patch created at {}Hz", 44100.0);
}
Run it:
cargo run
Building the Examples
Clone the repository and run an example:
git clone https://github.com/alexnodeland/quiver
cd quiver
cargo run --example simple_patch
Building Documentation
Generate the API documentation locally:
cargo doc --open
This opens the rustdoc documentation in your browser with all type information and examples.
Editor Setup
For the best experience, use an editor with Rust support:
- VS Code with rust-analyzer extension
- IntelliJ IDEA with Rust plugin
- Neovim with rust-tools.nvim
Type hints are particularly helpful given Quiver’s strong typing—your editor will show you exactly what signals flow where.
Next: Your First Patch
Your First Patch
Let’s build a complete synthesizer voice: an oscillator through a filter, shaped by an envelope. This is the classic subtractive synthesis signal path.
flowchart LR
VCO[VCO] -->|saw| VCF[VCF]
VCF -->|lowpass| VCA[VCA]
ADSR[ADSR] -->|env| VCF
ADSR -->|env| VCA
GATE[Gate] -->|trigger| ADSR
VCA --> OUT[Output]
The Complete Example
//! First Patch Example
//!
//! A complete subtractive synthesizer voice demonstrating the core
//! Quiver workflow: VCO → VCF → VCA with ADSR envelope shaping.
//!
//! 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!");
}
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 jackvcf.in_("in")— the filter’s audio input jack
Note: We use
in_()instead ofin()becauseinis a Rust keyword.
Compiling the Patch
patch.set_output(output.id());
patch.compile().unwrap();
Compilation:
- Performs topological sort (determines processing order)
- Validates all connections
- 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
| Stage | Module | Function |
|---|---|---|
| 1 | VCO | Generates raw waveform (saw wave) |
| 2 | VCF | Filters harmonics (lowpass) |
| 3 | VCA | Controls amplitude |
| 4 | ADSR | Shapes volume over time |
| 5 | Output | Routes 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:
-
Different waveform: Change
vco.out("saw")tovco.out("sqr")for a hollow, clarinet-like tone -
Add an LFO: Modulate the filter for a rhythmic wobble
-
Change envelope times: Longer attack for pads, shorter for percussion
Next: Understanding Signal Flow
Understanding Signal Flow
In Quiver, signals flow through modules following the conventions of hardware modular synthesizers. Understanding these conventions is key to creating patches that behave predictably.
Voltage Ranges
Quiver models its signals on the Eurorack standard:
graph LR
subgraph "Audio Signals"
A[±5V Peak<br/>AC-coupled]
end
subgraph "Control Voltage"
B[0-10V Unipolar]
C[±5V Bipolar]
end
subgraph "Pitch"
D[1V/Octave<br/>0V = C4]
end
subgraph "Triggers/Gates"
E[0V Low<br/>+5V High]
end
style A fill:#4a9eff,color:#fff
style B fill:#f9a826,color:#000
style C fill:#f9a826,color:#000
style D fill:#e74c3c,color:#fff
style E fill:#50c878,color:#fff
Audio Signals
Audio oscillates between -5V and +5V:
$$\text{audio}(t) \in [-5, +5]$$
This matches Eurorack levels and allows headroom for mixing.
Control Voltage (CV)
Two types of control voltage:
| Type | Range | Use Case |
|---|---|---|
| Unipolar | 0V to +10V | Filter cutoff, LFO rate, envelope times |
| Bipolar | -5V to +5V | Vibrato, 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.
| Voltage | Note | Frequency |
|---|---|---|
| -1V | C3 | 130.81 Hz |
| 0V | C4 | 261.63 Hz |
| +1V | C5 | 523.25 Hz |
| +2V | C6 | 1046.50 Hz |
Gates and Triggers
sequenceDiagram
participant G as Gate
participant T as Trigger
Note over G: Gate (sustained)
G->>G: 0V (off)
G->>G: +5V (on, held)
G->>G: +5V (still on)
G->>G: 0V (off)
Note over T: Trigger (impulse)
T->>T: 0V
T->>T: +5V (1-10ms pulse)
T->>T: 0V
- Gate: Sustained high signal (key held down)
- Trigger: Brief pulse (≈1-10ms) to start events
Signal Types in Code
Quiver tracks signal types through SignalKind:
pub enum SignalKind {
Audio, // ±5V AC-coupled
CvBipolar, // ±5V control
CvUnipolar, // 0-10V control
VoltPerOctave, // 1V/Oct pitch
Gate, // 0V or +5V sustained
Trigger, // 0V or +5V brief pulse
Clock, // Regular timing pulses
}
The type system helps catch mismatches:
// This will warn: connecting audio to a V/Oct input
patch.connect(vco.out("saw"), another_vco.in_("voct"))
Module Input Behavior
Input Summing
Multiple cables to one input are summed:
flowchart LR
LFO1[LFO 1] -->|+2V| SUM((Σ))
LFO2[LFO 2] -->|+3V| SUM
SUM -->|+5V| VCF[VCF cutoff]
This models analog behavior where multiple CVs combine.
Attenuverters
Many inputs support attenuation and inversion:
// Half strength, inverted
patch.connect_with(
lfo.out("sin"),
vcf.in_("cutoff"),
Cable::new().with_attenuation(-0.5),
)?;
The attenuverter range is typically -2 to +2, allowing inversion and some gain.
Normalled Connections
Some inputs have default sources when unpatched:
flowchart LR
LEFT[Left Input] --> NORM{Unpatched?}
NORM -->|Yes| RIGHT[Uses Left<br/>signal]
NORM -->|No| EXT[External<br/>source]
The StereoOutput module, for example, normalizes right to left if right is unpatched.
Processing Order
Quiver automatically determines processing order through topological sort:
flowchart TD
VCO[1. VCO] --> VCF[2. VCF]
LFO[1. LFO] --> VCF
VCF --> VCA[3. VCA]
ENV[1. ENV] --> VCA
VCA --> OUT[4. Output]
Modules with no dependencies process first. The algorithm (Kahn’s) ensures every module has its inputs ready before processing.
Common Patching Patterns
Modulation
flowchart LR
LFO[LFO] -->|mod| TARGET[Target Parameter]
OFFSET[Offset] -->|base| TARGET
Combine a static offset with an LFO for “center + modulation” control.
Envelope Following
flowchart LR
AUDIO[Audio In] --> VCA[VCA]
AUDIO --> ENV[Envelope<br/>Follower]
ENV -->|level| VCA
Use audio amplitude to control other parameters.
FM (Frequency Modulation)
flowchart LR
MOD[Modulator<br/>VCO] -->|fm| CARRIER[Carrier<br/>VCO]
CARRIER --> OUT[Output]
Audio-rate modulation of oscillator frequency creates complex timbres.
Next: The Quiver Philosophy
The Quiver Philosophy
Quiver isn’t just another DSP library. It’s built on a philosophy that bridges abstract mathematics with hands-on synthesis.
The Name
A quiver in category theory is a directed graph—a collection of objects connected by arrows. This is exactly what a modular synthesizer is:
graph LR
subgraph "Category Theory"
O1((Object)) -->|morphism| O2((Object))
O2 -->|morphism| O3((Object))
end
graph LR
subgraph "Modular Synthesis"
M1[Module] -->|cable| M2[Module]
M2 -->|cable| M3[Module]
end
The parallel is precise:
- Objects → Modules (signal processors)
- Morphisms/Arrows → Patch cables (signal flow)
- Composition → Signal chaining
- Identity → Pass-through modules
This isn’t mere analogy—it guides the entire API design.
Three Layers, One System
Quiver’s architecture reflects different levels of abstraction:
graph TB
subgraph "Layer 3: Patch Graph"
L3["Runtime flexibility<br/>Dynamic topology<br/>Hardware-like patching"]
end
subgraph "Layer 2: Port System"
L2["Signal conventions<br/>Type-erased interface<br/>Hardware semantics"]
end
subgraph "Layer 1: Typed Combinators"
L1["Compile-time safety<br/>Arrow composition<br/>Zero-cost abstractions"]
end
L1 --> L2 --> L3
style L1 fill:#4a9eff,color:#fff
style L2 fill:#f9a826,color:#000
style L3 fill:#50c878,color:#fff
Layer 1: Mathematical Purity
At the foundation, modules are Arrow combinators:
// Sequential composition: f >>> g
let chain = osc.chain(filter);
// Parallel composition: f *** g
let stereo = left.parallel(right);
// Fanout: f &&& g
let split = signal.fanout(fx1, fx2);
These operations are type-checked at compile time. If types don’t match, the program doesn’t compile.
Arrow Laws hold: $$\text{id} \ggg f = f = f \ggg \text{id}$$ $$(f \ggg g) \ggg h = f \ggg (g \ggg h)$$
Layer 2: Hardware Semantics
The port system brings real-world meaning:
- ±5V audio because that’s what mixers expect
- 1V/octave because that’s the pitch standard
- Gates and triggers because that’s how sequencers work
This layer ensures your patches behave like hardware.
Layer 3: Patching Freedom
The graph layer gives runtime flexibility:
// Add modules dynamically
let new_lfo = patch.add("wobble", Lfo::new(44100.0));
// Connect at runtime
patch.connect(new_lfo.out("sin"), vcf.in_("cutoff"))?;
// Recompile and continue
patch.compile()?;
This is how real modular synthesizers work—you can repatch while playing.
Design Principles
1. Type Safety Where It Matters
Quiver catches errors at compile time when possible:
// This won't compile: f64 can't go where (f64, f64) is expected
let bad_chain = mono_module.chain(stereo_module);
But it allows runtime flexibility when needed:
// This works: runtime type checking with clear error messages
patch.connect(vco.out("saw"), vcf.in_("cutoff"))
.expect("Signal type mismatch");
2. Zero-Cost Abstractions
Layer 1 combinators compile to the same code as hand-written loops:
// This combinator chain...
let synth = vco.chain(vcf).chain(vca);
// ...compiles to equivalent of:
fn tick(&mut self) -> f64 {
self.vca.tick(self.vcf.tick(self.vco.tick(())))
}
The abstraction is free—no runtime overhead.
3. Hardware-Inspired Defaults
Modules behave like their analog counterparts:
- VCO starts at C4 (0V = 261.63 Hz)
- ADSR has sensible attack/decay/sustain/release
- Filter resonance ranges from clean to self-oscillation
You can start patching immediately without configuring everything.
4. Progressive Complexity
Simple things are simple:
let output = vco.chain(output); // One line, done
Complex things are possible:
// Polyphonic patch with unison, analog modeling, SIMD processing
let poly = PolyPatch::new(voices, voice_patch)
.with_unison(UnisonConfig::new(3).with_detune(0.1))
.with_analog_variation(ComponentModel::default());
The Hybrid Approach
Most DSP libraries force a choice:
- Static: Great types, but can’t repatch at runtime
- Dynamic: Flexible, but crashes at runtime on type errors
Quiver offers both:
flowchart TB
subgraph "Programmatic (Layer 1)"
P1[Compile-time types]
P2[Arrow composition]
P3[Zero-cost]
end
subgraph "Runtime (Layer 3)"
R1[Dynamic patching]
R2[Signal validation]
R3[Topological sort]
end
P1 --> BRIDGE[GraphModule trait]
R1 --> BRIDGE
style BRIDGE fill:#f9a826,color:#000
Write your core DSP with full type safety, then expose it to the graph system for flexible routing.
Mathematical Foundations
The math isn’t decoration—it ensures correctness:
| Property | Musical Meaning |
|---|---|
| Associativity | Grouping doesn’t affect sound |
| Identity | Pass-through doesn’t color signal |
| Composition | Chaining is predictable |
| Functor laws | Signal mapping is consistent |
When you chain modules, you’re performing mathematical composition. The laws guarantee the result is what you expect.
Why This Matters
- Fewer bugs: Type system catches connection errors
- Better performance: Zero-cost abstractions
- Clearer thinking: Math clarifies design
- Hardware familiarity: Patches work like real modulars
Quiver aims to be the missing link between academic DSP theory and hands-on synthesis. The math makes it reliable; the hardware semantics make it intuitive.
Ready to dive deeper? Continue to Tutorials.
Basic Subtractive Synthesis
Subtractive synthesis is the foundation of analog synthesizers. Start with a harmonically rich waveform, then sculpt it by filtering away frequencies.
flowchart LR
OSC[Oscillator<br/>Rich harmonics] --> FILTER[Filter<br/>Remove harmonics]
FILTER --> AMP[Amplifier<br/>Shape volume]
AMP --> OUT[Output]
style OSC fill:#4a9eff,color:#fff
style FILTER fill:#f9a826,color:#000
style AMP fill:#50c878,color:#fff
The Physics of Waveforms
Different waveforms have different harmonic content:
| Waveform | Harmonics | Sound Character |
|---|---|---|
| Sine | Fundamental only | Pure, flute-like |
| Triangle | Odd harmonics (weak) | Soft, clarinet-like |
| Sawtooth | All harmonics | Bright, brassy |
| Square | Odd harmonics (strong) | Hollow, woody |
The mathematical representation:
Sawtooth wave: $$x(t) = \frac{2}{\pi} \sum_{k=1}^{\infty} \frac{(-1)^{k+1}}{k} \sin(2\pi k f t)$$
This infinite sum of harmonics is what gives the sawtooth its brightness.
Building the Patch
//! Tutorial: Basic Subtractive Synthesis
//!
//! This example demonstrates the fundamentals of subtractive synthesis:
//! starting with a harmonically rich oscillator and shaping it with a filter.
//!
//! # 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()
);
}
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
- Try different waveforms: Change
"saw"to"sqr"or"tri" - Adjust cutoff: Lower values = darker, muffled sound
- Add resonance: Creates a vowel-like quality
- Mix waveforms: Combine
sawandsqrfor thickness
Classic Tones
| Synth Sound | Waveform | Filter | Character |
|---|---|---|---|
| Moog Bass | Saw | LP, low cutoff | Fat, warm |
| Oberheim Pad | Saw + Saw (detuned) | LP, med cutoff | Lush, wide |
| TB-303 Acid | Saw | LP, high resonance | Squelchy |
| CS-80 Brass | Saw | LP, following envelope | Brassy 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
| Stage | Description | Typical Range |
|---|---|---|
| Attack | Time to reach peak (0→5V) | 1ms - 10s |
| Decay | Time to fall to sustain level | 1ms - 10s |
| Sustain | Level held while gate is high | 0V - 5V |
| Release | Time to return to zero | 1ms - 10s |
The Mathematics
Each stage is typically an exponential curve:
Attack (exponential rise): $$v(t) = V_{max} \cdot (1 - e^{-t/\tau_a})$$
Decay/Release (exponential fall): $$v(t) = V_{start} \cdot e^{-t/\tau_d}$$
Where $\tau$ is the time constant. Analog envelopes have this natural exponential shape—it’s how capacitors charge and discharge.
Building the Example
//! Tutorial: Envelope Shaping
//!
//! Demonstrates the ADSR envelope generator and how it shapes sound over time.
//! This is fundamental to giving synthesized sounds their character.
//!
//! # 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)");
}
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:
| Destination | Amount | Effect |
|---|---|---|
| VCA | 100% | Full volume control |
| VCF | 50% | Subtle brightness sweep |
| Pitch | 5% | Pitch “blip” on attack |
Musical Applications
Plucky Synth Bass
Attack: 5ms (instant)
Decay: 200ms (quick fall)
Sustain: 30% (some body)
Release: 100ms (clean cutoff)
Swelling Pad
Attack: 2s (slow fade in)
Decay: 500ms (gentle settle)
Sustain: 80% (full and rich)
Release: 3s (long tail)
Percussive Hit
Attack: 1ms (instant)
Decay: 50ms (very fast)
Sustain: 0% (no sustain)
Release: 50ms (immediate)
Envelope Stages Visualization
sequenceDiagram
participant G as Gate
participant E as Envelope
Note over G,E: Note On
G->>E: Gate HIGH (+5V)
E->>E: Attack phase (rising)
E->>E: Decay phase (falling)
E->>E: Sustain phase (holding)
Note over G,E: Note Off
G->>E: Gate LOW (0V)
E->>E: Release phase (falling to 0)
Next: Filter Modulation
Filter Modulation
Modulation brings patches to life. When we connect an LFO (Low Frequency Oscillator) to the filter cutoff, static becomes dynamic—a still photograph becomes a movie.
flowchart LR
LFO[LFO<br/>~2Hz] -->|mod| VCF[Filter<br/>Cutoff]
VCO[VCO] -->|audio| VCF
VCF --> OUT[Output]
style LFO fill:#f9a826,color:#000
LFO: The Modulation Source
An LFO is simply an oscillator running at sub-audio rates:
| Audio Oscillator | LFO |
|---|---|
| 20Hz - 20kHz | 0.01Hz - 30Hz |
| Creates pitch | Creates movement |
| You hear it | You 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");
}
Modulation Depth and Attenuverters
The amount of modulation matters:
| Depth | Effect |
|---|---|
| 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
| Rate | Depth | Classic Use |
|---|---|---|
| 0.5Hz | 30% | Slow filter sweep |
| 2Hz | 10% | Subtle shimmer |
| 6Hz | 50% | Dubstep wobble |
| 8Hz | 5% | Guitar vibrato |
Multiple Modulation Sources
Combine LFO with envelope for evolving sounds:
flowchart TD
LFO[LFO<br/>Ongoing movement]
ENV[Envelope<br/>Per-note shape]
SUM((Σ))
VCF[Filter Cutoff]
LFO --> SUM
ENV --> SUM
SUM --> VCF
The envelope provides the initial “brightness burst,” while the LFO adds continuous movement during sustain.
Next: Building a Sequenced Bass
Building a Sequenced Bass
Let’s create something musical: a step sequencer driving a bass synthesizer. This is the foundation of countless electronic music tracks.
flowchart LR
CLK[Clock] -->|tempo| SEQ[Step<br/>Sequencer]
SEQ -->|V/Oct| VCO[VCO]
SEQ -->|gate| ENV[ADSR]
VCO --> VCF[VCF]
ENV --> VCF
ENV --> VCA[VCA]
VCF --> VCA
VCA --> OUT[Output]
style SEQ fill:#e74c3c,color:#fff
style CLK fill:#50c878,color:#fff
The Step Sequencer
A step sequencer cycles through a series of values, advancing on each clock pulse:
Step: 1 2 3 4 5 6 7 8
CV: ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐
│ │ │ │ │ │ │ │ │ │ │ │
Gate: └─┘ └─┘ └─┘ └─┘ └─┘ └─┘
C3 D3 rest G3 C3 rest E3 D3
Each step can have:
- CV value: The pitch (in V/Oct)
- Gate: On or off (rest = off)
V/Oct and Musical Pitches
Converting notes to voltages:
| Note | MIDI | V/Oct |
|---|---|---|
| C3 | 48 | -1.0V |
| C4 | 60 | 0.0V |
| D4 | 62 | +0.167V |
| E4 | 64 | +0.333V |
| G4 | 67 | +0.583V |
| C5 | 72 | +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.");
}
Clock Divisions
The clock module provides multiple time divisions:
graph TB
MASTER[Master Clock<br/>120 BPM] --> D1[1/1<br/>Whole notes]
MASTER --> D2[1/2<br/>Half notes]
MASTER --> D4[1/4<br/>Quarter notes]
MASTER --> D8[1/8<br/>Eighth notes]
MASTER --> D16[1/16<br/>Sixteenth notes]
For a bassline at 120 BPM:
- 1/8 notes = 4 Hz (classic house tempo)
- 1/16 notes = 8 Hz (driving techno)
Filter Envelope Relationship
The key to punchy bass is the filter envelope:
Attack: Fast (5ms)
Decay: Medium (100-200ms)
Sustain: Low (20-40%)
Release: Quick (50-100ms)
This creates the characteristic “pluck” where brightness fades quickly.
Accent and Dynamics
Real sequences have accents—emphasized notes. Implement with velocity:
sequenceDiagram
participant SEQ as Sequencer
participant ENV as Envelope
SEQ->>ENV: Step 1 (normal)
Note over ENV: Attack → Sustain
SEQ->>ENV: Step 2 (accented)
Note over ENV: Attack → Higher peak<br/>→ Sustain
Classic Patterns
House Bass
Step: 1 2 3 4 5 6 7 8
Note: C - C - C - C C
The off-beat creates the groove.
Acid (TB-303 Style)
Step: 1 2 3 4 5 6 7 8
Note: C C D - F - D C
Acc: X X
Slide: → →
Accents and slides define the style.
Minimal Techno
Step: 1 2 3 4 5 6 7 8
Note: C - - - C - - -
Space and repetition create hypnotic effect.
Going Further
- Add slide/portamento with
SlewLimiter - Randomize steps with
BernoulliGate - Quantize to scale with
Quantizer - Layer with detuned second VCO
Next: FM Synthesis Basics
FM Synthesis Basics
Frequency Modulation (FM) synthesis creates complex timbres by modulating one oscillator’s frequency with another. It’s the technology behind the DX7 and countless digital synths.
flowchart LR
MOD[Modulator<br/>Oscillator] -->|FM| CAR[Carrier<br/>Oscillator]
CAR --> OUT[Output]
style MOD fill:#f9a826,color:#000
style CAR fill:#4a9eff,color:#fff
The Mathematics
In FM synthesis, the carrier frequency is modulated by the modulator:
$$y(t) = A \sin(2\pi f_c t + I \sin(2\pi f_m t))$$
Where:
- $f_c$ = carrier frequency (the pitch you hear)
- $f_m$ = modulator frequency
- $I$ = modulation index (depth)
- $A$ = amplitude
The modulation index controls harmonic richness:
| Index | Sound Character |
|---|---|
| 0 | Pure sine (no modulation) |
| 1-2 | Warm, mellow |
| 3-5 | Bright, electric piano-like |
| 6+ | Harsh, metallic |
The Carrier:Modulator Ratio
The frequency ratio determines the harmonic structure:
| C:M Ratio | Result |
|---|---|
| 1:1 | Symmetric harmonics |
| 1:2 | Octave-related harmonics |
| 2:1 | Subharmonics present |
| 1:1.414 | Inharmonic (bell-like) |
| 1:3.5 | Metallic, 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.");
}
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
| Aspect | Subtractive | FM |
|---|---|---|
| Harmonics | Remove from rich source | Generate from sine waves |
| CPU | Filter computation | Multiple oscillators |
| Character | Warm, analog | Bright, digital |
| Control | Intuitive | Parameter-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”?
| Strategy | Description | Best For |
|---|---|---|
| RoundRobin | Steal oldest voice | Even wear |
| QuietestSteal | Steal softest voice | Minimal artifacts |
| OldestSteal | Steal note held longest | Predictable |
| NoSteal | Ignore new notes | Pad sounds |
| HighestPriority | High notes steal low | Melodies |
| LowestPriority | Low notes steal high | Bass 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 ¬e 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 ¬e 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
Freeonly 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
Releasingare 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 Note | Name | V/Oct |
|---|---|---|
| 48 | C3 | -1.0V |
| 60 | C4 | 0.0V |
| 72 | C5 | +1.0V |
| 84 | C6 | +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 aPortRef.vcf.in_("in")— an input jack. Spelledin_()becauseinis 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:
| Module | Inputs | Outputs |
|---|---|---|
Vco | voct, fm, pw, sync, fm_lin | sin, tri, saw, sqr |
Svf | in, cutoff, res, fm, keytrack, keytrack_amt | lp, bp, hp, notch |
Adsr | gate, retrig, attack, decay, sustain, release, shape | env, inv, eoc |
Vca | in, cv, response, gain | out |
StereoOutput | left, 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.0is unity,0.5half strength,-1.0inverts,2.0doubles (watch for clipping). - offset is clamped to
-10.0..=10.0volts 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
- Name modules clearly —
"filter_lfo", not"lfo2". - Keep
Warnvalidation on during development to catch signal-kind mistakes. - Check port specs when unsure of names, rather than guessing.
- Break feedback with a
UnitDelay(orDelayLine) socompile()succeeds. - 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 Method | Signal Type | Range |
|---|---|---|
::voct() | V/Oct pitch | ±10V |
::gate() | Gate | 0-5V |
::trigger() | Trigger | 0-5V |
::cv() | Unipolar CV | 0-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 Note | V/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
AtomicF64uses 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, ®istry, 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 ID | Module |
|---|---|
vco | Vco |
svf | Svf |
adsr | Adsr |
vca | Vca |
lfo | Lfo |
mixer | Mixer |
stereo_output | StereoOutput |
| … | (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, ®istry, 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, ®istry, 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
- Version your patches: Include version numbers for future compatibility
- Document parameters: Use description fields liberally
- Test round-trips: Verify patches load correctly after saving
- Handle missing modules: Gracefully handle unknown module types
- Separate external I/O: Document which external connections are needed
Create Custom Modules
Extend Quiver with your own DSP modules using the Module Development Kit (MDK).
The GraphModule Trait
Every module in Layer 3 implements GraphModule:
pub trait GraphModule: Send {
fn port_spec(&self) -> PortSpec;
fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues);
fn reset(&mut self);
fn set_sample_rate(&mut self, sample_rate: f64);
}
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_rateinnew. 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_ratestores 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). Theirset_sample_rateis 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, ®istry, 44100.0)?;
Best Practices
- Validate inputs: Clamp CV values to expected ranges
- Handle edge cases: Zero crossings, near-zero values
- Avoid allocations: No heap allocations in
tick() - Document signal ranges: Specify expected voltage ranges
- Test thoroughly: Use the test harness before shipping
Visualize Your Patch
Quiver provides tools to visualize patch topology and analyze signals.
DOT/GraphViz Export
Generate visual diagrams of your patch:
use quiver::prelude::*;
let patch = /* your patch */;
// Create exporter with default style
let exporter = DotExporter::new(&patch);
let dot = exporter.to_dot();
println!("{}", dot);
Save to file and render:
# Save DOT output
cargo run > patch.dot
# Render with GraphViz
dot -Tpng patch.dot -o patch.png
dot -Tsvg patch.dot -o patch.svg
Styling Options
Customize the visualization:
let style = DotStyle::new()
.with_theme("dark") // dark, light, minimal
.with_rankdir("LR") // LR (left-right) or TB (top-bottom)
.with_show_port_names(true)
.with_signal_colors(true); // Color-code by signal type
let exporter = DotExporter::with_style(&patch, style);
Signal type colors:
- Audio: Blue
- CV: Orange
- Gate/Trigger: Green
- V/Oct: Red
Example Output
flowchart LR
subgraph Oscillators
VCO[VCO]
LFO[LFO]
end
subgraph Processing
VCF[VCF]
VCA[VCA]
end
subgraph Envelope
ADSR[ADSR]
end
VCO -->|saw| VCF
LFO -->|sin| VCF
VCF -->|lp| VCA
ADSR -->|env| VCF
ADSR -->|env| VCA
VCA --> Output
style VCO fill:#4a9eff
style LFO fill:#f9a826
style ADSR fill:#50c878
Oscilloscope
Monitor signals in real-time:
let scope = Scope::new(44100.0)
.with_buffer_size(1024)
.with_trigger_mode(TriggerMode::RisingEdge);
// In your audio loop
let sample = patch.tick().0;
scope.write(sample);
// Get waveform for display
let waveform = scope.buffer();
Trigger modes:
Free: Continuous displayRisingEdge: Trigger on positive zero-crossingFallingEdge: Trigger on negative zero-crossingSingle: One-shot capture
Spectrum Analyzer
View frequency content:
let analyzer = SpectrumAnalyzer::new(44100.0);
// Feed samples
for sample in samples.iter() {
analyzer.write(*sample);
}
// Get spectrum data
let bins = analyzer.bins(); // Frequency bins
let magnitudes = analyzer.magnitudes(); // dB values
// Find dominant frequency
let peak_freq = analyzer.peak_frequency();
println!("Fundamental: {:.1} Hz", peak_freq);
Level Meter
Monitor audio levels:
let mut meter = LevelMeter::new(44100.0)
.with_peak_hold(500.0); // 500ms peak hold
// Process samples
for sample in samples.iter() {
meter.write(*sample);
}
println!("RMS: {:.1} dB", meter.rms_db());
println!("Peak: {:.1} dB", meter.peak_db());
Automation Recording
Record parameter changes:
let mut recorder = AutomationRecorder::new();
// Create a track for filter cutoff
let track_id = recorder.create_track("filter_cutoff");
// Record automation points
recorder.record(track_id, 0.0, 0.5); // Time 0s: 0.5
recorder.record(track_id, 1.0, 0.8); // Time 1s: 0.8
recorder.record(track_id, 2.0, 0.2); // Time 2s: 0.2
// Get automation data
let data = recorder.data();
let json = serde_json::to_string(&data)?;
Example: Complete Visualization
//! How-To: Visualize Your Patch
//!
//! Demonstrates patch visualization including DOT export,
//! signal analysis, and metering.
//!
//! Run with: cargo run --example howto_visualization
use quiver::prelude::*;
fn main() {
let sample_rate = 44100.0;
println!("=== Patch Visualization Demo ===\n");
// Build a patch to visualize
let mut patch = Patch::new(sample_rate);
let vco = patch.add("vco", Vco::new(sample_rate));
let lfo = patch.add("lfo", Lfo::new(sample_rate));
let vcf = patch.add("vcf", Svf::new(sample_rate));
let vca = patch.add("vca", Vca::new());
let env = patch.add("env", Adsr::new(sample_rate));
let output = patch.add("output", StereoOutput::new());
// Connections
patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
patch.connect(lfo.out("sin"), vcf.in_("fm")).unwrap();
patch.connect(vcf.out("lp"), vca.in_("in")).unwrap();
patch.connect(env.out("env"), vcf.in_("cutoff")).unwrap();
patch.connect(env.out("env"), vca.in_("cv")).unwrap();
patch.connect(vca.out("out"), output.in_("left")).unwrap();
patch.connect(vca.out("out"), output.in_("right")).unwrap();
patch.set_output(output.id());
patch.compile().unwrap();
// Generate DOT visualization
println!("--- DOT Graph Output ---");
println!("(Save this to a .dot file and render with GraphViz)\n");
let style = DotStyle::default();
let dot = DotExporter::export(&patch, &style);
println!("{}", dot);
// Generate audio for analysis
println!("\n--- Signal Analysis ---\n");
// Collect samples
let num_samples = (sample_rate * 0.5) as usize;
let mut samples = Vec::with_capacity(num_samples);
for _ in 0..num_samples {
let (left, _) = patch.tick();
samples.push(left);
}
// Basic statistics
let peak = samples.iter().map(|s| s.abs()).fold(0.0_f64, f64::max);
let rms = (samples.iter().map(|s| s * s).sum::<f64>() / num_samples as f64).sqrt();
let dc_offset = samples.iter().sum::<f64>() / num_samples as f64;
println!("Sample Statistics:");
println!(" Samples: {}", num_samples);
println!(
" Peak: {:.3}V ({:.1} dB)",
peak,
20.0 * (peak / 5.0).log10()
);
println!(" RMS: {:.3}V ({:.1} dB)", rms, 20.0 * (rms / 5.0).log10());
println!(" DC Offset: {:.6}V", dc_offset);
// Estimate frequency via zero crossings
let mut zero_crossings = 0;
for i in 1..samples.len() {
if (samples[i] >= 0.0) != (samples[i - 1] >= 0.0) {
zero_crossings += 1;
}
}
let estimated_freq = zero_crossings as f64 / 2.0 / (num_samples as f64 / sample_rate);
println!(" Estimated Frequency: {:.1} Hz", estimated_freq);
// ASCII waveform visualization
println!("\n--- Waveform (ASCII) ---\n");
let display_samples = 80; // Characters wide
let step = samples.len() / display_samples;
for row in (0..11).rev() {
let threshold = (row as f64 - 5.0) / 5.0 * peak;
let mut line = String::new();
for col in 0..display_samples {
let sample = samples[col * step];
if (sample >= threshold && row > 5) || (sample <= threshold && row < 5) {
line.push('█');
} else if row == 5 {
line.push('─');
} else {
line.push(' ');
}
}
let label = match row {
10 => "+peak",
5 => " 0V ",
0 => "-peak",
_ => " ",
};
println!("{} |{}", label, line);
}
// Using the Scope module
println!("\n--- Scope Analysis ---\n");
let mut scope = Scope::new(1024); // Buffer size in samples
// Recreate patch for fresh samples
patch.compile().unwrap();
// Fill scope buffer
for _ in 0..1024 {
let (left, _) = patch.tick();
scope.tick(left);
}
let buffer = scope.buffer_vec();
println!("Scope buffer size: {} samples", buffer.len());
// Using LevelMeter
println!("\n--- Level Meter ---\n");
let mut meter = LevelMeter::new(sample_rate);
for _ in 0..(sample_rate * 0.1) as usize {
let (left, _) = patch.tick();
meter.tick(left);
}
println!("Level Meter:");
println!(" RMS Level: {:.2} dB", meter.rms());
println!(" Peak Level: {:.2} dB", meter.peak());
// Module graph summary
println!("\n--- Patch Summary ---\n");
println!("Modules: {}", patch.node_count());
println!("Cables: {}", patch.cable_count());
println!("\nTo visualize graphically:");
println!(" 1. Save the DOT output above to 'patch.dot'");
println!(" 2. Run: dot -Tpng patch.dot -o patch.png");
println!(" 3. Open patch.png in an image viewer");
}
Integration with GUIs
The visualization data is designed for easy GUI integration:
// For immediate-mode GUIs (egui, imgui)
for (i, magnitude) in analyzer.magnitudes().enumerate() {
let freq = i as f64 * sample_rate / fft_size;
draw_bar(freq, magnitude);
}
// For retained-mode GUIs
let path: Vec<(f64, f64)> = scope.buffer()
.enumerate()
.map(|(i, sample)| (i as f64, *sample))
.collect();
draw_path(&path);
Browser & App Integration
This guide explains how to integrate Quiver into your browser application using the WASM bindings and npm packages.
Overview
Quiver provides three npm packages for browser integration:
| Package | Purpose |
|---|---|
@quiver-dsp/wasm | Core WASM engine and AudioWorklet utilities |
@quiver-dsp/react | React hooks for UI integration |
@quiver-dsp/types | TypeScript 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();
Block Processing (Recommended)
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_blockyourself — 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 - Browse and search available modules
- Observable Streaming - Real-time visualization data
- Serialization - Save and load patches
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 Rusttype_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: "..." }
status | Meaning | UI hint |
|---|---|---|
exact | Identical signal type | Green cable |
allowed | Different but valid | Normal cable |
warning | Works, but may clip or mismatch | Yellow 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
| Type | Description | Data |
|---|---|---|
Param | Parameter value changes | { value: f64 } |
Level | Audio level metering | { rms_db: f64, peak_db: f64 } |
Gate | Binary on/off state | { active: bool } |
Scope | Waveform samples | { samples: f32[] } |
Spectrum | FFT 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:
| Size | Duration @ 44.1kHz | Use Case |
|---|---|---|
| 128 | 2.9ms | Fast updates, percussion |
| 256 | 5.8ms | General purpose |
| 512 | 11.6ms | Smooth waveforms |
| 1024 | 23.2ms | Low-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 Size | Bins | Freq Resolution @ 44.1kHz |
|---|---|---|
| 128 | 64 | 344 Hz |
| 256 | 128 | 172 Hz |
| 512 | 256 | 86 Hz |
| 1024 | 512 | 43 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
- Subscribe only to what you display - Unused subscriptions waste CPU
- Use appropriate buffer sizes - Larger = less CPU, slower updates
- Throttle UI updates - 60fps is usually sufficient
- Batch DOM updates - Use
requestAnimationFramegrouping - 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:
| Combinator | Signature | Purpose |
|---|---|---|
chain | A → B then B → C = A → C | Sequential composition |
parallel | (A → B) *** (C → D) = (A,C) → (B,D) | Parallel processing |
fanout | A → B and A → C = A → (B,C) | Split input |
first | (A → B) on (A, X) = (B, X) | Process first element |
feedback | Loop with unit delay | Recursion |
Type Safety Example
// This compiles: types match
let synth = vco.chain(vcf).chain(vca);
// Vco: () → f64
// Svf: f64 → f64
// Vca: f64 → f64
// Result: () → f64 ✓
// This won't compile: type mismatch
let bad = vco.chain(stereo_module);
// Vco: () → f64
// StereoModule: (f64, f64) → (f64, f64)
// Error: expected f64, found (f64, f64) ✗
Layer 2: Port System
The middle layer adds hardware semantics through signal types and port definitions.
Signal Kinds
pub enum SignalKind {
Audio, // ±5V AC-coupled audio
CvBipolar, // ±5V control voltage
CvUnipolar, // 0-10V control voltage
VoltPerOctave, // 1V/Oct pitch standard
Gate, // 0V or +5V sustained
Trigger, // 0V or +5V brief pulse
Clock, // Regular timing pulses
}
Port Definitions
let spec = PortSpec::new()
.with_input("in", PortDef::audio())
.with_input("cutoff", PortDef::cv_unipolar().with_default(5.0))
.with_output("lp", PortDef::audio())
.with_output("hp", PortDef::audio());
The GraphModule Trait
Bridges typed modules to the graph:
pub trait GraphModule: Send {
fn port_spec(&self) -> PortSpec;
fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues);
fn reset(&mut self);
fn set_sample_rate(&mut self, sample_rate: f64);
}
Layer 3: Patch Graph
The top layer provides runtime-configurable topology for maximum flexibility.
The Patch Container
pub struct Patch {
nodes: SlotMap<NodeId, Box<dyn GraphModule>>,
cables: Vec<Cable>,
output_node: Option<NodeId>,
processing_order: Vec<NodeId>,
}
Key Operations
// Add modules
let vco = patch.add("vco", Vco::new(44100.0));
// Connect ports
patch.connect(vco.out("saw"), vcf.in_("in"))?;
// Compile for processing
patch.compile()?;
// Process audio
let (left, right) = patch.tick();
Graph Processing
Compilation performs:
- Topological sort (Kahn’s algorithm)
- Cycle detection (no feedback without explicit delay)
- 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
| Layer | Use When |
|---|---|
| Layer 1 | Building DSP algorithms with type safety |
| Layer 2 | Defining module interfaces for reuse |
| Layer 3 | Creating 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:
- Objects: Things we’re studying (modules)
- Morphisms: Transformations between objects (signal flow)
- Composition: Combining morphisms (chaining modules)
- 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 Theory | Quiver Audio |
|---|---|
| Objects | Signal types (f64, (f64, f64)) |
| Morphisms | Modules (Vco, Svf, Vca) |
| Composition | chain combinator |
| Identity | Identity module |
The Identity Law
// Identity does nothing
let id = Identity::<f64>::new();
// f >>> id = f
let same1 = vco.chain(id);
// id >>> f = f
let same2 = id.chain(vco);
The Associativity Law
// These produce identical behavior:
let way1 = (vco.chain(vcf)).chain(vca);
let way2 = vco.chain(vcf.chain(vca));
// Grouping doesn't matter—only the order
Arrows: Richer Structure
Quiver uses Arrow semantics, an extension of categories that adds:
First and Second
Apply a morphism to part of a pair:
// first: (A → B) → ((A, X) → (B, X))
let process_left = filter.first(); // Filter left channel only
// second: (A → B) → ((X, A) → (X, B))
let process_right = filter.second(); // Filter right channel only
Parallel Composition (⊗)
Process two signals independently:
$$f \otimes g : (A, C) \to (B, D)$$
// Process stereo with different filters
let stereo = left_filter.parallel(right_filter);
// (f64, f64) → (f64, f64)
Fanout (Δ)
Duplicate input to multiple processors:
$$\Delta_f^g : A \to (B, C)$$
// Send same input to two effects
let split = reverb.fanout(delay);
// f64 → (f64, f64)
The Arrow Laws
For arrows $f$, $g$, $h$:
Composition with first:
$$\text{first}(f \ggg g) = \text{first}(f) \ggg \text{first}(g)$$
Identity with first:
$$\text{first}(\text{id}) = \text{id}$$
Commutativity: $$\text{first}(f) \ggg (\text{id} \times g) = (\text{id} \times g) \ggg \text{first}(f)$$
These laws ensure that complex compositions behave predictably.
Why This Matters
1. Predictable Behavior
The laws guarantee that refactoring preserves behavior:
// These are equivalent by associativity
let v1 = a.chain(b.chain(c));
let v2 = a.chain(b).chain(c);
// Safe to refactor between them
2. Type-Driven Design
Types prevent invalid connections:
// Type error: can't chain mono into stereo
let bad = mono_module.chain(stereo_module);
// ^^^^^^^^^^^ f64
// ^^^^^^^^^^^^^^^^^ (f64, f64)
3. Compositionality
Build complex systems from simple parts:
// Each piece is simple
let voice = vco.chain(vcf).chain(vca);
let effects = delay.chain(reverb);
let mixer = voice.fanout(effects).chain(mix);
// Composition creates complexity
Quivers vs Categories
A quiver is “pre-categorical”—it has the structure but not necessarily composition:
graph LR
subgraph "Quiver (Graph)"
Q1((1)) -->|a| Q2((2))
Q2 -->|b| Q3((3))
end
subgraph "Category (+ Composition)"
C1((1)) -->|a| C2((2))
C2 -->|b| C3((3))
C1 -.->|b∘a| C3
end
Quiver (the library) sits at this boundary:
- Layer 3 is quiver-like: arbitrary graph structure
- Layer 1 is category-like: composition is built-in
The Free Category
Given a quiver, the free category adds all possible compositions. This is exactly what compile() does—it computes the transitive closure of signal flow.
// Define edges (quiver)
patch.connect(a, b);
patch.connect(b, c);
// compile() computes the free category
patch.compile()?;
// Now signal flows: a → b → c
Further Reading
- Categories for the Working Mathematician — Saunders Mac Lane
- Category Theory for Programmers — Bartosz Milewski
- Seven Sketches in Compositionality — Fong & Spivak
Arrow Combinators
Quiver’s Layer 1 provides Arrow-style combinators for composing DSP modules with compile-time type safety.
The Core Abstraction
Every module is a function from input to output:
$$M : \text{In} \to \text{Out}$$
Combinators let us build complex modules from simple ones without losing type safety.
Chain (Sequential Composition)
The most fundamental combinator: output of first feeds input of second.
flowchart LR
IN[Input] --> A[Module A]
A --> B[Module B]
B --> OUT[Output]
$$\text{chain}(f, g) = g \circ f : A \to C$$
let synth = vco.chain(vcf).chain(vca);
// () → f64 → f64 → f64
// Types flow through automatically
Parallel (Independent Processing)
Process two signals independently:
flowchart LR
subgraph Input
I1[A]
I2[C]
end
subgraph Processing
M1[Module F]
M2[Module G]
end
subgraph Output
O1[B]
O2[D]
end
I1 --> M1 --> O1
I2 --> M2 --> O2
$$(f \parallel g)(a, c) = (f(a), g(c))$$
let stereo = left_channel.parallel(right_channel);
// (f64, f64) → (f64, f64)
Fanout (Split and Process)
Send input to multiple processors:
flowchart LR
IN[Input A] --> SPLIT((•))
SPLIT --> F[Module F]
SPLIT --> G[Module G]
F --> O1[B]
G --> O2[C]
$$\text{fanout}(f, g)(a) = (f(a), g(a))$$
let effects = signal.fanout(reverb, delay);
// f64 → (f64, f64)
First and Second
Apply a module to only one part of a pair:
flowchart LR
subgraph "first(F)"
I1[A] --> F[F]
I2[X] --> P[Pass]
F --> O1[B]
P --> O2[X]
end
$$\text{first}(f)(a, x) = (f(a), x)$$
// Process only the left channel
let left_only = filter.first();
// (f64, f64) → (f64, f64)
Feedback (With Delay)
Create a feedback loop with unit delay:
flowchart LR
IN[Input] --> SUM((+))
SUM --> PROC[Process]
PROC --> OUT[Output]
PROC --> DEL[z⁻¹]
DEL --> SUM
$$y[n] = f(x[n] + y[n-1])$$
let echo = delay.feedback(0.5); // 50% feedback
Map and Contramap
Transform signals without creating new modules:
// Map: transform output
let boosted = vco.map(|x| x * 2.0);
// Contramap: transform input
let scaled = vca.contramap(|x| x * 0.5);
flowchart LR
subgraph "map(f, g)"
IN[A] --> M[Module]
M --> TRANS[g]
TRANS --> OUT[C]
end
Identity
The do-nothing module—but type-safe:
let id = Identity::<f64>::new();
// f64 → f64, output equals input
// Useful for type alignment
let aligned = mono.parallel(Identity::new());
Constant
Always produce the same output:
let dc = Constant::new(5.0);
// () → f64, always 5.0
// Useful for fixed CV values
let offset = Constant::new(2.5).chain(adder.second());
Split and Merge
Work with tuples:
// Split: duplicate input
let dup = Split::<f64>::new();
// f64 → (f64, f64)
// Merge: combine with function
let summer = Merge::new(|a, b| a + b);
// (f64, f64) → f64
Swap
Swap tuple elements:
let swapped = Swap::<f64, f64>::new();
// (A, B) → (B, A)
Combining Combinators
Build complex signal flow:
// Classic synth voice with stereo chorus
let voice = vco
.chain(vcf)
.chain(vca)
.chain(Split::new()) // Mono to stereo
.chain(
chorus_left.parallel(chorus_right)
)
.chain(
Merge::new(|l, r| (l * 0.5, r * 0.5))
);
Type Inference
Rust’s type inference works through combinators:
// Types are inferred
let synth = vco.chain(vcf).chain(vca);
// Compiler knows: () → f64
// Explicit types when needed
let stereo: Chain<VCO, Parallel<VCF, VCF>> = ...;
Zero-Cost Abstraction
Combinators compile to efficient code:
// This combinator chain...
let synth = vco.chain(vcf).chain(vca);
// ...compiles to essentially:
fn tick(&mut self) -> f64 {
self.vca.tick(
self.vcf.tick(
self.vco.tick(())
)
)
}
No heap allocation, no virtual dispatch, no runtime overhead.
Pattern: Effect Rack
fn effect_rack(
effects: Vec<Box<dyn Module<In=f64, Out=f64>>>
) -> impl Module<In=f64, Out=f64>
{
effects.into_iter()
.fold(Identity::new(), |acc, fx| acc.chain(fx))
}
Pattern: Parallel Voices
fn parallel_voices<V: Module<In=f64, Out=f64>>(
voices: [V; 4]
) -> impl Module<In=(f64, f64, f64, f64), Out=(f64, f64, f64, f64)>
{
let [v1, v2, v3, v4] = voices;
v1.parallel(v2).parallel(v3).parallel(v4)
}
Signal Conventions
Quiver adopts hardware modular synthesizer conventions for signal levels and types. Understanding these is essential for creating patches that behave predictably.
Voltage Standards
The library models signals on the Eurorack standard:
graph TB
subgraph "Signal Types"
AUDIO["Audio<br/>±5V peak"]
CVBI["CV Bipolar<br/>±5V"]
CVUNI["CV Unipolar<br/>0-10V"]
VOCT["V/Oct<br/>±10V"]
GATE["Gate<br/>0V / +5V"]
TRIG["Trigger<br/>0V / +5V pulse"]
end
style AUDIO fill:#4a9eff,color:#fff
style CVBI fill:#f9a826,color:#000
style CVUNI fill:#f9a826,color:#000
style VOCT fill:#e74c3c,color:#fff
style GATE fill:#50c878,color:#fff
style TRIG fill:#50c878,color:#fff
Audio Signals
Audio oscillates symmetrically around zero:
$$\text{audio}(t) \in [-5V, +5V]$$
- Nominal level: ±5V peak
- AC-coupled: No DC offset
- Bandwidth: 20Hz - 20kHz
// VCO outputs are ±5V audio
let saw = vco.out("saw"); // -5V to +5V
Clipping
Signals exceeding ±5V may clip at later stages:
graph LR
INPUT[+8V<br/>signal] --> CLIP[Clipping<br/>Stage]
CLIP --> OUTPUT[+5V<br/>clipped]
Some modules add soft saturation to avoid harsh clipping.
Control Voltage (CV)
Unipolar CV (0-10V)
For parameters that don’t make sense negative:
| Parameter | Example Values |
|---|---|
| Filter cutoff | 0V = 20Hz, 10V = 20kHz |
| LFO rate | 0V = 0.01Hz, 10V = 30Hz |
| Envelope times | 0V = 1ms, 10V = 10s |
PortDef::cv_unipolar().with_default(5.0)
Bipolar CV (±5V)
For parameters that can go both ways:
| Parameter | Example 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
| Voltage | Note | MIDI | Frequency |
|---|---|---|---|
| -3V | C1 | 24 | 32.70 Hz |
| -2V | C2 | 36 | 65.41 Hz |
| -1V | C3 | 48 | 130.81 Hz |
| 0V | C4 | 60 | 261.63 Hz |
| +1V | C5 | 72 | 523.25 Hz |
| +2V | C6 | 84 | 1046.50 Hz |
| +3V | C7 | 96 | 2093.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:
| Output | Division |
|---|---|
div_1 | Whole notes |
div_2 | Half notes |
div_4 | Quarter notes |
div_8 | Eighth notes |
div_16 | Sixteenth 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 → | Audio | CV Bi | CV Uni | V/Oct | Gate |
|---|---|---|---|---|---|
| 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
| Effect | Use Case |
|---|---|
| Saturation | Warmth, harmonics, preventing clipping |
| Component tolerance | Unique character per voice |
| Thermal drift | Slow organic movement |
| V/Oct errors | Vintage oscillator feel |
| HF rolloff | Soften digital harshness |
| Crosstalk | Subtle stereo interaction |
| Ground loop | Vintage 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:
- Complete within the buffer deadline
- Have bounded, predictable latency
- 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
| Aspect | Sample-by-Sample | Block |
|---|---|---|
| Function call overhead | Per sample | Per block |
| Cache efficiency | Poor | Good |
| SIMD opportunity | None | Full |
| Branch prediction | Frequent | Rare |
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_buffer — before 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.1", features = ["simd"] }
SIMD Operations
use quiver::simd::*;
let mut block = AudioBlock::new();
// SIMD-accelerated operations
block.add_scalar(offset); // Add constant
block.mul_scalar(gain); // Multiply by constant
block.add_block(&other); // Add another block
block.mul_block(&envelope); // Multiply by envelope
// These use SSE/AVX when available
Alignment
SIMD requires aligned memory:
// AudioBlock is automatically aligned
let block = AudioBlock::new(); // 16-byte aligned
// Manual alignment for custom types
#[repr(align(16))]
struct MyBuffer([f64; 64]);
Lazy Evaluation
Defer computation until needed:
use quiver::simd::{LazySignal, LazyBlock};
// Create lazy signal
let lazy = LazySignal::new(|| expensive_computation());
// Value computed only when needed
let value = lazy.evaluate();
// Lazy block operations
let lazy_block = LazyBlock::new()
.add_scalar(1.0)
.mul_scalar(0.5)
.add_block(&other);
// All operations fused when materialized
let result = lazy_block.materialize();
Fusion Benefits
// Without fusion: 3 loops
for s in block { s += 1.0; }
for s in block { s *= 0.5; }
for s in block { s += other[i]; }
// With fusion: 1 loop
for i in 0..len {
block[i] = (block[i] + 1.0) * 0.5 + other[i];
}
Ring Buffers
Efficient delay lines:
use quiver::simd::RingBuffer;
let mut delay = RingBuffer::new(44100); // 1 second
// Write sample, get delayed sample
let delayed = delay.tick(input);
// Access specific delay
let tapped = delay.read(11025); // 0.25 second delay
flowchart LR
IN[Input] --> WRITE[Write<br/>Head]
WRITE --> BUF[Circular<br/>Buffer]
BUF --> READ[Read<br/>Head]
READ --> OUT[Output]
WRITE -.->|wrap| WRITE
READ -.->|wrap| READ
ProcessContext
Bundle processing state:
let ctx = ProcessContext {
sample_rate: 44100.0,
block_size: 64,
transport_position: 0,
is_playing: true,
};
module.process_with_context(&mut block, &ctx);
Best Practices
1. Preallocate Everything
// Do this once at startup
let mut block = AudioBlock::new();
let mut delay = RingBuffer::new(max_delay);
// Not in the audio callback
let block = AudioBlock::new(); // ❌ Allocation!
2. Avoid Branching in Inner Loops
// Bad: branch per sample
for i in 0..len {
if condition {
block[i] = process_a(block[i]);
} else {
block[i] = process_b(block[i]);
}
}
// Good: branch once per block
if condition {
for i in 0..len { block[i] = process_a(block[i]); }
} else {
for i in 0..len { block[i] = process_b(block[i]); }
}
3. Use Block Operations
// Bad: call per sample
for i in 0..len {
block[i] = vco.tick();
}
// Good: block processing
vco.process(&[], &mut block);
4. Profile Regularly
use std::time::Instant;
let start = Instant::now();
process_block(&mut block);
let duration = start.elapsed();
if duration.as_secs_f64() * 1000.0 > 2.9 {
eprintln!("Warning: approaching deadline!");
}
Memory Layout
Cache-Friendly Access
// Good: sequential access
for i in 0..len {
output[i] = input[i] * gain;
}
// Bad: strided access
for i in (0..len).step_by(4) {
output[i] = input[i] * gain;
}
Structure of Arrays
For multiple parallel signals:
// Array of Structures (cache unfriendly)
struct Voice { phase: f64, freq: f64, amp: f64 }
let voices: [Voice; 8];
// Structure of Arrays (cache friendly)
struct Voices {
phases: [f64; 8],
freqs: [f64; 8],
amps: [f64; 8],
}
Real-Time Latency Constraints
Real-time audio processing requires strict timing guarantees. This guide explains latency budgets, how to calculate them, and strategies for meeting real-time constraints.
The Fundamental Constraint
Audio hardware delivers samples in fixed-size buffers at regular intervals. Your processing must complete before the next buffer arrives, or you’ll hear clicks, pops, or dropouts.
sequenceDiagram
participant HW as Audio Hardware
participant CPU as Your Code
HW->>CPU: Buffer N arrives
Note over CPU: Process buffer
CPU->>HW: Buffer N complete
HW->>CPU: Buffer N+1 arrives
Note over CPU: Must finish before<br/>next buffer!
Time Budget Calculation
The time budget is determined by:
time_budget = buffer_size / sample_rate
Common Configurations
| Sample Rate | Buffer 64 | Buffer 128 | Buffer 256 | Buffer 512 |
|---|---|---|---|---|
| 44.1 kHz | 1.45 ms | 2.90 ms | 5.80 ms | 11.61 ms |
| 48 kHz | 1.33 ms | 2.67 ms | 5.33 ms | 10.67 ms |
| 96 kHz | 0.67 ms | 1.33 ms | 2.67 ms | 5.33 ms |
| 192 kHz | 0.33 ms | 0.67 ms | 1.33 ms | 2.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 Size | Time @ 48 kHz | Use Case |
|---|---|---|
| 16 samples | 0.33 ms | Hardware-like response |
| 32 samples | 0.67 ms | Professional monitoring |
| 48 samples | 1.00 ms | Live performance |
| 64 samples | 1.33 ms | Studio 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)
| Buffer | Round-Trip @ 48 kHz |
|---|---|
| 64 | 2.67 ms |
| 128 | 5.33 ms |
| 256 | 10.67 ms |
| 512 | 21.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
| Voices | Recommended Buffer | Notes |
|---|---|---|
| 1-4 | 64-128 samples | Low latency possible |
| 8-16 | 128-256 samples | Typical synthesizer |
| 32+ | 256-512 samples | High 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:
| Operation | Alternative |
|---|---|
| File I/O | Preload samples |
| Network | Use separate thread |
| Mutex locks | Use lock-free atomics |
| Memory allocation | Preallocate buffers |
| Console output | Log 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:
| Module | Relative Cost | Notes |
|---|---|---|
| VCA | 1× | Baseline |
| LFO | 1× | Simple oscillator |
| ADSR | 1× | Envelope |
| VCO | 2× | Multiple waveforms |
| SVF | 3× | State-variable filter |
| DiodeLadder | 5× | Nonlinear modeling |
| Wavefolder | 4× | Saturation math |
Complex patches scale accordingly:
| Patch Type | Typical Modules | Relative Cost |
|---|---|---|
| Simple | VCO → VCF → VCA | ~6× |
| Modulated | + LFO, ADSR | ~8× |
| Complex | 2×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 configsbuffer_processing: Per-buffer-size timingpolyphony/voice_scaling: Voice count impactstress/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
- Increase buffer size - Try doubling it
- Reduce polyphony - Fewer voices = faster
- Simplify patches - Remove expensive modules
- Check background processes - CPU spikes cause glitches
- Profile the patch - Find the bottleneck
High CPU Usage
- Compile the patch - Ensure
patch.compile()was called - Use SVF over DiodeLadder - 40% cheaper
- Reduce unison - Each adds full voice cost
- Lower sample rate - 44.1 kHz vs 96 kHz
- Enable SIMD -
features = ["simd"]
Inconsistent Timing
- Disable CPU scaling - Set performance governor
- Isolate audio thread - Pin to dedicated core
- Increase thread priority - Real-time scheduling
- Check thermal throttling - Cool your CPU
Summary
| Scenario | Buffer | Latency | Max Voices |
|---|---|---|---|
| Live instrument | 64 | 1.33 ms | 4-8 |
| Studio tracking | 128 | 2.67 ms | 8-16 |
| Mixing | 256 | 5.33 ms | 16-32 |
| Mastering | 512+ | 10+ ms | Unlimited |
The key principles:
- Know your budget:
buffer_size / sample_rate - Preallocate everything: No allocations in callbacks
- Profile regularly: Measure, don’t guess
- Leave headroom: Target 70% CPU max
- 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
| Port | Signal | Range | Description |
|---|---|---|---|
voct | V/Oct | ±5V | Pitch (0V = C4) |
fm | Bipolar CV | ±5V | Exponential FM (±5V ≈ ±5 octaves) |
pw | Unipolar CV | 0-10V | Pulse width, default 50% |
sync | Gate | 0/5V | Hard sync reset |
fm_lin | Bipolar CV | ±5V | Linear through-zero FM (±5V ≈ ±100% of base freq) |
Outputs
| Port | Signal | Description |
|---|---|---|
sin | Audio | Sine wave |
tri | Audio | Triangle wave (bandlimited, PolyBLAMP) |
saw | Audio | Sawtooth wave (bandlimited, PolyBLEP) |
sqr | Audio | Square/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
| Port | Signal | Range | Description |
|---|---|---|---|
rate | Unipolar CV | 0-10V | Frequency (0.01-30 Hz) |
depth | Unipolar CV | 0-10V | Output amplitude |
reset | Trigger | 0/5V | Phase reset |
Outputs
| Port | Signal | Description |
|---|---|---|
sin | Bipolar CV | Sine wave (±5V) |
tri | Bipolar CV | Triangle wave |
saw | Bipolar CV | Sawtooth wave |
sqr | Bipolar CV | Square wave |
sin_uni | Unipolar CV | Unipolar sine (0-10V) |
Rate Mapping
Default rate curve: $$f = 0.01 \cdot e^{(\text{CV}/10) \cdot \ln(3000)}$$
| CV | Frequency |
|---|---|
| 0V | 0.01 Hz |
| 5V | ~1 Hz |
| 10V | 30 Hz |
Noise Generator
White and pink noise sources with a CV-controllable stereo second channel.
let noise = patch.add("noise", NoiseGenerator::new());
Inputs
| Port | Signal | Range | Description |
|---|---|---|---|
correlation | Unipolar CV | 0-10V | Stereo correlation (0 = independent, 1 = identical), default 0.3 |
Outputs
| Port | Signal | Description |
|---|---|---|
white | Audio | White noise |
pink | Audio | Pink noise |
white2 | Audio | Second white channel, correlated with white |
pink2 | Audio | Second 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
| Port | Signal | Range | Description |
|---|---|---|---|
voct | V/Oct | ±5V | Pitch (0V = C4) |
detune | Unipolar CV | 0-10V | Detune spread of the 7 voices, default 50% |
mix | Unipolar CV | 0-10V | Blend between center voice and full supersaw, default 50% |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Audio | Mixed 7-oscillator supersaw |
sub | Audio | Octave-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
| Port | Signal | Range | Description |
|---|---|---|---|
v_oct | V/Oct | ±5V | Pitch (0V = C4) |
table | Unipolar CV | 0-10V | Table select across 8 tables |
morph | Unipolar CV | 0-10V | Crossfade morph between adjacent tables |
sync | Gate | 0/5V | Hard sync (resets phase) |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Audio | Wavetable 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
| Port | Signal | Range | Description |
|---|---|---|---|
v_oct | V/Oct | ±5V | Pitch (0V = C4) |
vowel | Unipolar CV | 0-10V | Interpolates the vowel A → E → I → O → U |
formant_shift | Bipolar CV | ±5V | Shifts all formant frequencies (0.5×–2×) |
vibrato | Unipolar CV | 0-10V | Vibrato depth (up to ±0.5 semitone) |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Audio | Vocal 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
| Port | Signal | Range | Description |
|---|---|---|---|
voct | V/Oct | ±5V | Pitch → string period |
trigger | Trigger | 0/5V | Rising edge plucks the string |
damping | Unipolar CV | 0-10V | Loop lowpass amount, default 50% |
brightness | Unipolar CV | 0-10V | Noise-vs-impulse excitation blend, default 50% |
stretch | Bipolar CV | ±5V | All-pass stretch / inharmonicity |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Audio | Plucked-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
| Port | Signal | Range | Description |
|---|---|---|---|
trig | Trigger | 0/5V | Rising edge starts one-shot playback |
gate | Gate | 0/5V | Gated playback (release stops it) |
voct | V/Oct | ±5V | Playback pitch (0V = unity rate) |
start | Unipolar CV | 0-10V | Start position (0–1 of buffer) |
loop | Gate | 0/5V | Enables looping when high |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Audio | Sample output |
eos | Trigger | Fires 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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
cutoff | Unipolar CV | 0-10V | Cutoff frequency (20 Hz–20 kHz, exponential) |
res | Unipolar CV | 0-10V | Resonance (0–1) |
fm | Bipolar CV | ±5V | Linear FM added to cutoff |
keytrack | V/Oct | ±5V | Keyboard tracking pitch |
keytrack_amt | Unipolar CV | 0-10V | Keyboard tracking amount (0–1) |
Outputs
| Port | Signal | Description |
|---|---|---|
lp | Audio | Lowpass (removes highs) |
bp | Audio | Bandpass (passes band) |
hp | Audio | Highpass (removes lows) |
notch | Audio | Notch (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
| CV | Frequency |
|---|---|
| 0V | 20 Hz |
| 5V | ~630 Hz |
| 10V | 20,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.
| Resonance | Character |
|---|---|
| 0.0 | Flat response |
| 0.5 | Slight peak |
| 0.9 | Prominent peak |
| 1.0 | Self-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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
cutoff | Unipolar CV | 0-10V | Cutoff frequency (20 Hz–20 kHz, exponential) |
res | Unipolar CV | 0-10V | Resonance (0–1; feedback k = res·4) |
fm | Bipolar CV | ±5V | Linear FM added to cutoff |
keytrack | V/Oct | ±5V | Keyboard tracking pitch |
keytrack_amt | Unipolar CV | 0-10V | Keyboard tracking amount (0–1) |
drive | Unipolar CV | 0-10V | Input drive (gain 1×–4×) |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Audio | 24 dB/oct main output |
pole1 | Audio | 6 dB/oct tap |
pole2 | Audio | 12 dB/oct tap |
pole3 | Audio | 18 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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
low_gain | Bipolar CV | ±5V | Low-shelf gain (±12 dB) |
low_freq | Unipolar CV | 0-10V | Low-shelf frequency (50–500 Hz) |
mid_gain | Bipolar CV | ±5V | Mid peaking gain (±12 dB) |
mid_freq | Unipolar CV | 0-10V | Mid frequency (200 Hz–8 kHz) |
mid_q | Unipolar CV | 0-10V | Mid Q (0.5–10) |
high_gain | Bipolar CV | ±5V | High-shelf gain (±12 dB) |
high_freq | Unipolar CV | 0-10V | High-shelf frequency (2–12 kHz) |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Audio | Equalized 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
| Sound | Cutoff | Resonance | Notes |
|---|---|---|---|
| Warm bass | Low | Low | Full body |
| Acid squelch | Swept | High | TB-303 style |
| Vocal formant | Mid | High | Vowel-like |
| Bright lead | High | Medium | Cutting |
| Underwater | Very low | Low | Muffled |
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
| Port | Signal | Range | Description |
|---|---|---|---|
gate | Gate | 0/5V | Gate on/off |
retrig | Trigger | 0/5V | Retrigger (restarts attack from the current level) |
attack | Unipolar CV | 0-10V | Attack time, default 0.1 |
decay | Unipolar CV | 0-10V | Decay time (true segment duration), default 0.3 |
sustain | Unipolar CV | 0-10V | Sustain level, default 0.7 |
release | Unipolar CV | 0-10V | Release time (true segment duration), default 0.4 |
shape | Gate | 0/5V | 0V = linear ramps, high = exponential one-pole |
Outputs
| Port | Signal | Description |
|---|---|---|
env | Unipolar CV | Envelope output (0-10V) |
inv | Unipolar CV | Inverted envelope |
eoc | Trigger | End-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
| Sound | Attack | Decay | Sustain | Release |
|---|---|---|---|---|
| Pluck | 5ms | 200ms | 0% | 100ms |
| Pad | 1s | 500ms | 80% | 2s |
| Brass | 50ms | 100ms | 70% | 200ms |
| Perc | 1ms | 50ms | 0% | 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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
attack | Unipolar CV | 0-10V | Detector attack time, default 0.2 |
release | Unipolar CV | 0-10V | Detector release time, default 0.3 |
gain | Unipolar CV | 0-10V | Output gain (×4), default 0.5 |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Unipolar CV | Amplitude envelope (0-10V) |
inv | Unipolar CV | Inverted 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
| Port | Signal | Description |
|---|---|---|
in | CV/Audio | Signal to sample |
trigger | Trigger | When to sample |
Output
| Port | Signal | Description |
|---|---|---|
out | CV | Held 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
| Port | Signal | Description |
|---|---|---|
in | CV | Input signal |
rise | Unipolar CV | Rise time (upward slew) |
fall | Unipolar CV | Fall time (downward slew) |
Output
| Port | Signal | Description |
|---|---|---|
out | CV | Slewed 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
| Port | Signal | Description |
|---|---|---|
in | V/Oct | Unquantized pitch |
Output
| Port | Signal | Description |
|---|---|---|
out | V/Oct | Quantized 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
| Port | Signal | Range | Description |
|---|---|---|---|
in | V/Oct | ±5V | Pitch to quantize |
root | Unipolar CV | 0-10V | Root note (0–11 semitones) |
scale | Unipolar CV | 0-10V | Scale select (7 built-in scales) |
Outputs
| Port | Signal | Description |
|---|---|---|
out | V/Oct | Quantized pitch |
trigger | Trigger | Fires 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
| Port | Signal | Description |
|---|---|---|
tempo | Unipolar CV | BPM (0-10V = 20-300 BPM) |
reset | Trigger | Reset to beat 1 |
Outputs
| Port | Signal | Description |
|---|---|---|
div_1 | Trigger | Whole notes |
div_2 | Trigger | Half notes |
div_4 | Trigger | Quarter notes |
div_8 | Trigger | Eighth notes |
div_16 | Trigger | Sixteenth notes |
div_32 | Trigger | 32nd notes |
Step Sequencer
8-step CV/gate sequencer.
let seq = patch.add("seq", StepSequencer::new());
Inputs
| Port | Signal | Description |
|---|---|---|
clock | Trigger | Advance to next step |
reset | Trigger | Return to step 1 |
Outputs
| Port | Signal | Description |
|---|---|---|
cv | V/Oct | Step CV value |
gate | Gate | Step 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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
threshold | Unipolar CV | 0-10V | Threshold (×5 V), default 0.5 |
ratio | Unipolar CV | 0-10V | Ratio (1:1 – 20:1), default 0.5 |
attack | Unipolar CV | 0-10V | Attack time, default 0.2 |
release | Unipolar CV | 0-10V | Release time, default 0.3 |
makeup | Unipolar CV | 0-10V | Makeup gain (1×–4×), default 0.0 |
sidechain | Audio | ±5V | External key; normalled to in when unpatched |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Audio | Compressed output |
gr | Unipolar CV | Gain-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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
threshold | Unipolar CV | 0-10V | Ceiling (×5 V), default 0.8 |
release | Unipolar CV | 0-10V | Release time, default 0.3 |
soft | Gate | 0/5V | Soft (tanh) knee vs hard limiting; default on |
sidechain | Audio | ±5V | External key; normalled to in when unpatched |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Audio | Limited output (hard-clamped to ±threshold) |
gr | Unipolar CV | Gain 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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
threshold | Unipolar CV | 0-10V | Open threshold (×5 V; close = 0.7×), default 0.1 |
attack | Unipolar CV | 0-10V | Detector attack, default 0.1 |
release | Unipolar CV | 0-10V | Detector release, default 0.3 |
range | Unipolar CV | 0-10V | Maximum attenuation depth, default 1.0 |
sidechain | Audio | ±5V | External key; normalled to in when unpatched |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Audio | Gated output |
gate | Gate | Gate 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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Main signal |
key | Audio | ±5V | Sidechain key that drives the ducking |
amount | Bipolar CV | ±5V | Duck depth CV (summed with the knob) |
threshold | Bipolar CV | ±5V | Threshold CV (summed with the knob) |
attack | Unipolar CV | 0-10V | Envelope attack, default 0.1 |
release | Unipolar CV | 0-10V | Envelope release, default 0.3 |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Audio | Ducked output |
gr | Unipolar CV | Gain 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
| Port | Signal | Description |
|---|---|---|
in_1 - in_4 | Audio | Audio inputs |
gain_1 - gain_4 | Unipolar CV | Channel gains |
master | Unipolar CV | Master gain |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Mixed output |
VCA (Voltage-Controlled Amplifier)
Controls signal amplitude with CV.
let vca = patch.add("vca", Vca::new());
Inputs
| Port | Signal | Description |
|---|---|---|
in | Audio | Audio input |
cv | Unipolar CV | Gain control (0-10V = 0-100%) |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Amplitude-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
| Port | Signal | Description |
|---|---|---|
in | Any | Input signal |
amount | Bipolar CV | Scale factor (-2 to +2) |
Output
| Port | Signal | Description |
|---|---|---|
out | Any | Scaled output |
Amount Values
| Amount | Effect |
|---|---|
| -2.0 | Inverted and doubled |
| -1.0 | Inverted |
| 0.0 | Silent |
| 0.5 | Half level |
| 1.0 | Unity (unchanged) |
| 2.0 | Doubled |
Offset
Adds DC offset (constant voltage source).
let offset = patch.add("offset", Offset::new(5.0)); // 5V
Output
| Port | Signal | Description |
|---|---|---|
out | CV | Constant 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
| Port | Signal | Description |
|---|---|---|
in | Any | Input signal |
Outputs
| Port | Signal | Description |
|---|---|---|
out_1 - out_4 | Any | Identical copies |
UnitDelay
Single-sample delay (z⁻¹).
let delay = patch.add("delay", UnitDelay::new());
Input/Output
| Port | Signal | Description |
|---|---|---|
in | Any | Input |
out | Any | Delayed by 1 sample |
Essential for feedback loops.
Crossfader
Crossfade between two signals with equal-power curve.
let xfade = patch.add("xfade", Crossfader::new());
Inputs
| Port | Signal | Description |
|---|---|---|
a | Audio | First signal |
b | Audio | Second signal |
mix | Unipolar CV | Crossfade position |
pan | Bipolar CV | Stereo position |
Outputs
| Port | Signal | Description |
|---|---|---|
left | Audio | Left output |
right | Audio | Right 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
| Port | Signal | Description |
|---|---|---|
a | V/Oct | First pitch |
b | V/Oct | Second pitch (offset) |
Output
| Port | Signal | Description |
|---|---|---|
out | V/Oct | Sum 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
| Port | Signal | Description |
|---|---|---|
left | Audio | Left channel |
right | Audio | Right 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
| Method | Signal Type |
|---|---|
::voct() | V/Oct pitch |
::gate() | Gate signal |
::trigger() | Trigger |
::cv() | Unipolar CV |
::cv_bipolar() | Bipolar CV |
Output
| Port | Signal | Description |
|---|---|---|
out | Varies | External 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
| Port | Signal | Description |
|---|---|---|
left | Audio | Left channel |
right | Audio | Right channel |
Outputs
| Port | Signal | Description |
|---|---|---|
mid | Audio | (L + R) / 2 |
side | Audio | (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
| Port | Signal | Range | Description |
|---|---|---|---|
mid | Audio | ±5V | Mid channel |
side | Audio | ±5V | Side channel |
width | Unipolar CV | 0-10V | Stereo width (0 = mono, 1 = identity, 2 = doubled), default 1.0 |
Outputs
| Port | Signal | Description |
|---|---|---|
left | Audio | M + S·width |
right | Audio | M − 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());
| Inputs | Output |
|---|---|
| 0V, 0V | 0V |
| 0V, 5V | 0V |
| 5V, 0V | 0V |
| 5V, 5V | 5V |
LogicOr
Outputs HIGH when either input is HIGH.
let or_gate = patch.add("or", LogicOr::new());
| Inputs | Output |
|---|---|
| 0V, 0V | 0V |
| 0V, 5V | 5V |
| 5V, 0V | 5V |
| 5V, 5V | 5V |
LogicXor
Outputs HIGH when exactly one input is HIGH.
let xor_gate = patch.add("xor", LogicXor::new());
| Inputs | Output |
|---|---|
| 0V, 0V | 0V |
| 0V, 5V | 5V |
| 5V, 0V | 5V |
| 5V, 5V | 0V |
LogicNot
Inverts the input.
let not_gate = patch.add("not", LogicNot::new());
| Input | Output |
|---|---|
| 0V | 5V |
| 5V | 0V |
Comparators
Comparator
Compares two voltages.
let cmp = patch.add("cmp", Comparator::new());
Inputs
| Port | Signal | Description |
|---|---|---|
a | CV | First signal |
b | CV | Second signal |
Outputs
| Port | Signal | Description |
|---|---|---|
gt | Gate | HIGH if A > B |
lt | Gate | HIGH if A < B |
eq | Gate | HIGH 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
| Port | Description | Formula |
|---|---|---|
full | Full-wave rectified | $ |
half_pos | Positive half only | $\max(x, 0)$ |
half_neg | Negative half only | $\min(x, 0)$ |
abs | Absolute value | $ |
Input: ╱╲ ╱╲
╱ ╲╱ ╲
Full: ╱╲╱╲╱╲╱╲
Half+: ╱╲ ╱╲
──╲╱──╲╱
Half-: ╲╱ ╲╱
── ──
Audio Applications
- Octave doubling (full-wave rectify audio)
- Envelope following (rectify + lowpass)
- Distortion effects
Signal Routing
VcSwitch
Voltage-controlled signal router.
let switch = patch.add("switch", VcSwitch::new());
Inputs
| Port | Signal | Description |
|---|---|---|
a | Any | First signal |
b | Any | Second signal |
select | Gate | Which to output |
Output
| Port | Signal | Description |
|---|---|---|
out | Any | Selected 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
| Port | Signal | Description |
|---|---|---|
trigger | Trigger | Input trigger |
probability | Unipolar CV | Chance of A (0-100%) |
Outputs
| Port | Signal | Description |
|---|---|---|
a | Trigger | Probabilistic output A |
b | Trigger | Probabilistic 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
| Port | Signal | Description |
|---|---|---|
carrier | Audio | Carrier signal |
modulator | Audio | Modulator signal |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Product (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
| Port | Signal | Description |
|---|---|---|
v_oct | V/Oct | Input note to capture |
gate | Gate | Captures/releases the note on rising/falling edge |
clock | Clock | Advances the sequence |
pattern | Unipolar CV | Pattern select (Up / Down / UpDown / Random) |
octaves | Unipolar CV | Octave range (1–4) |
reset | Gate | Resets the sequence and clears held notes |
Outputs
| Port | Signal | Description |
|---|---|---|
v_oct_out | V/Oct | Arpeggiated pitch |
gate_out | Gate | Gate output (follows the clock) |
trigger | Trigger | Pulse 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
| Port | Signal | Description |
|---|---|---|
root | V/Oct | Root note of the chord |
chord | Unipolar CV | Chord-type select (9 types) |
inversion | Unipolar CV | Inversion (rotates the bass note) |
spread | Unipolar CV | Spreads voices across octaves |
Outputs
| Port | Signal | Description |
|---|---|---|
voice1 | V/Oct | Chord voice 1 |
voice2 | V/Oct | Chord voice 2 |
voice3 | V/Oct | Chord voice 3 |
voice4 | V/Oct | Chord 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
| Port | Signal | Description |
|---|---|---|
clock | Trigger | Advances the pattern on rising edge |
steps | Unipolar CV | Step count (2–16), default 0.5 |
pulses | Unipolar CV | Pulse (fill) count, default 0.25 |
rotation | Unipolar CV | Rotates the pattern |
reset | Trigger | Resets the step counter |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Trigger | Pulse output for active steps |
accent | Trigger | Accent 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
| Port | Signal | Description |
|---|---|---|
in | Audio | Input signal |
drive | Unipolar CV | Saturation amount |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Saturated output |
Saturation Types
| Function | Character |
|---|---|
tanh_sat | Smooth, tube-like |
soft_clip | Adjustable knee |
asym_sat | Even harmonics |
diode_clip | Hard, 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
| Port | Signal | Description |
|---|---|---|
in | Audio | Input signal |
threshold | Unipolar CV | Fold threshold (default = constructor value) |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Folded 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
| Port | Signal | Description |
|---|---|---|
left | Audio | Left channel |
right | Audio | Right channel |
amount | Unipolar CV | Bleed amount (0-10%) |
Outputs
| Port | Signal | Description |
|---|---|---|
left | Audio | Left with right bleed |
right | Audio | Right 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
| Port | Signal | Description |
|---|---|---|
amount | Unipolar CV | Hum level |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Hum signal |
Configuration
let hum = GroundLoop::new(44100.0)
.with_frequency(60.0) // 60Hz (US) or 50Hz (EU)
.with_harmonics(3); // Include 2nd and 3rd harmonics
Mix very subtly for vintage authenticity.
Scope
Real-time waveform visualization.
let scope = patch.add("scope", Scope::new(44100.0));
Inputs
| Port | Signal | Description |
|---|---|---|
in | Audio | Signal to display |
trigger | Gate | Trigger sync |
Trigger Modes
| Mode | Description |
|---|---|
Free | Continuous display |
RisingEdge | Sync on positive zero-cross |
FallingEdge | Sync on negative zero-cross |
Single | One-shot capture |
Reading the Buffer
let waveform = scope.buffer();
// Vec<f64> of recent samples
Spectrum Analyzer
FFT-based frequency analysis.
let analyzer = patch.add("spectrum", SpectrumAnalyzer::new(44100.0));
Input
| Port | Signal | Description |
|---|---|---|
in | Audio | Signal to analyze |
Reading Data
let bins = analyzer.bins(); // Frequency bins
let mags = analyzer.magnitudes(); // dB values
let peak = analyzer.peak_frequency(); // Dominant frequency
Level Meter
RMS and peak level monitoring.
let meter = patch.add("meter", LevelMeter::new(44100.0));
Input
| Port | Signal | Description |
|---|---|---|
in | Audio | Signal to meter |
Reading Levels
let rms = meter.rms(); // RMS level in volts
let peak = meter.peak(); // Peak level
let rms_db = meter.rms_db(); // RMS in dB
Peak Hold
let meter = LevelMeter::new(44100.0)
.with_peak_hold(500.0); // 500ms hold time
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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
drive | Unipolar CV | 0-10V | Drive into the shaper, default 0.5 |
tone | Unipolar CV | 0-10V | Tone (one-pole lowpass), default 0.5 |
mode | Unipolar CV | 0-10V | Algorithm select (4 modes) |
mix | Unipolar CV | 0-10V | Dry/wet, default fully wet |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Distorted output |
Bitcrusher
Lo-fi bit-depth and sample-rate reduction. type_id: bitcrusher.
let crush = patch.add("crush", Bitcrusher::new());
Inputs
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
bits | Unipolar CV | 0-10V | Bit-depth reduction (~1–16 bits), default 0.5 |
downsample | Unipolar CV | 0-10V | Sample-rate reduction |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Crushed 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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
time | Unipolar CV | 0-10V | Delay time (1 ms–2 s, exponential), default 0.5 |
feedback | Unipolar CV | 0-10V | Feedback (0–0.99) |
mix | Unipolar CV | 0-10V | Dry/wet, default 0.5 |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Mixed 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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
rate | Unipolar CV | 0-10V | LFO rate (0.1–5 Hz), default 0.3 |
depth | Unipolar CV | 0-10V | Modulation depth (0–25 ms), default 0.5 |
mix | Unipolar CV | 0-10V | Dry/wet, default 0.5 |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Audio | Mono mixed output |
left | Audio | Left stereo-spread output |
right | Audio | Right 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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
rate | Unipolar CV | 0-10V | LFO rate, default 0.3 |
depth | Unipolar CV | 0-10V | Sweep depth, default 0.5 |
feedback | Bipolar CV | ±5V | Feedback (−0.95–0.95) |
mix | Unipolar CV | 0-10V | Dry/wet, default 0.5 |
spread | Unipolar CV | 0-10V | Stereo L/R decorrelation (0 = mono, 1 = 180°), default 0.5 |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Audio | Legacy mono output (mirrors left) |
left | Audio | Left channel |
right | Audio | Right 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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
rate | Unipolar CV | 0-10V | LFO rate, default 0.3 |
depth | Unipolar CV | 0-10V | Notch sweep depth, default 0.7 |
feedback | Bipolar CV | ±5V | Feedback (−0.95–0.95) |
mix | Unipolar CV | 0-10V | Dry/wet, default 0.5 |
stages | Unipolar CV | 0-10V | Allpass stage count (<0.33 → 2, <0.66 → 4, else 6) |
spread | Unipolar CV | 0-10V | Stereo L/R decorrelation, default 0.5 |
Outputs
| Port | Signal | Description |
|---|---|---|
out | Audio | Legacy mono output (mirrors left) |
left | Audio | Left channel |
right | Audio | Right 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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
rate | Unipolar CV | 0-10V | LFO rate (0.1–20 Hz), default 0.3 |
depth | Unipolar CV | 0-10V | Modulation depth, default 0.5 |
shape | Unipolar CV | 0-10V | LFO shape blend (sine ↔ triangle) |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Amplitude-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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
rate | Unipolar CV | 0-10V | LFO rate (0.1–15 Hz), default 0.3 |
depth | Unipolar CV | 0-10V | Pitch-modulation depth, default 0.5 |
mix | Unipolar CV | 0-10V | Dry/wet, default fully wet |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Pitch-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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
size | Unipolar CV | 0-10V | Room size / decay, default 0.5 |
damping | Unipolar CV | 0-10V | High-frequency damping, default 0.5 |
mix | Unipolar CV | 0-10V | Dry/wet, default 0.5 |
predelay | Unipolar CV | 0-10V | Pre-delay (0–100 ms) |
Outputs
| Port | Signal | Description |
|---|---|---|
left | Audio | Left reverb channel |
right | Audio | Right 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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
shift | Bipolar CV | ±5V | Pitch shift (±24 semitones) |
window | Unipolar CV | 0-10V | Grain window (10–100 ms), default 0.5 |
mix | Unipolar CV | 0-10V | Dry/wet, default fully wet |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Pitch-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
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio recorded into the buffer |
position | Unipolar CV | 0-10V | Playback position, default 0.5 |
size | Unipolar CV | 0-10V | Grain size (10–500 ms), default 0.3 |
density | Unipolar CV | 0-10V | Grains per second (1–20), default 0.5 |
pitch | Bipolar CV | ±5V | Pitch shift (±24 semitones) |
spray | Unipolar CV | 0-10V | Position randomization, default 0.1 |
freeze | Gate | 0/5V | Stops recording while high |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Granular 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
| Port | Signal | Range | Description |
|---|---|---|---|
carrier | Audio | ±5V | Carrier (typically an oscillator) |
modulator | Audio | ±5V | Modulator (typically voice) |
bands | Unipolar CV | 0-10V | Band count (4–16), default 1.0 |
attack | Unipolar CV | 0-10V | Envelope-follower attack, default 0.3 |
release | Unipolar CV | 0-10V | Envelope-follower release, default 0.3 |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Vocoded 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
| Port | Signal | Description |
|---|---|---|
left | Audio | Left channel |
right | Audio | Right channel |
Normalled Behavior
If only left is connected, right automatically mirrors it.
// Mono output - left copied to right
patch.connect(mono_source, output.in_("left"))?;
// Stereo output
patch.connect(left_source, output.in_("left"))?;
patch.connect(right_source, output.in_("right"))?;
Getting Output
let (left, right) = patch.tick(); // Returns (f64, f64)
ExternalInput
Injects values from external sources (MIDI, UI, etc.).
use std::sync::Arc;
let cv = Arc::new(AtomicF64::new(0.0));
let input = patch.add("cv_in", ExternalInput::new(
Arc::clone(&cv),
SignalKind::CvUnipolar,
));
Factory Methods
| Method | Signal Kind | Typical Use |
|---|---|---|
::voct(arc) | V/Oct | Pitch from MIDI |
::gate(arc) | Gate | Note on/off |
::trigger(arc) | Trigger | Clock pulses |
::cv(arc) | Unipolar CV | Mod wheel, expression |
::cv_bipolar(arc) | Bipolar CV | Pitch bend |
Thread-Safe Updates
// From MIDI thread
cv.set(midi_cc_value / 127.0 * 10.0);
// Audio thread reads latest value
let input_module = ExternalInput::cv(Arc::clone(&cv));
MidiState
Comprehensive MIDI state tracking.
let midi = MidiState::new();
// In MIDI callback
midi.note_on(60, 100); // Note 60, velocity 100
midi.note_off(60);
midi.control_change(1, 64); // CC1 = 64
midi.pitch_bend(8192); // Center
// Read current state
let voct = midi.voct(); // V/Oct of current note
let gate = midi.gate(); // Gate state (0 or 5V)
let velocity = midi.velocity(); // 0.0 - 1.0
let mod_wheel = midi.cc(1); // CC value
OSC Integration
OscInput
Receives OSC messages as CV.
let osc_in = patch.add("cutoff_osc", OscInput::new("/synth/cutoff"));
patch.connect(osc_in.out("out"), vcf.in_("cutoff"))?;
OscReceiver
Network OSC receiver.
let receiver = OscReceiver::new("127.0.0.1:9000")?;
// In your control thread
while let Some(msg) = receiver.recv()? {
match msg.address.as_str() {
"/synth/cutoff" => {
if let Some(OscValue::Float(v)) = msg.args.first() {
cutoff_cv.set(*v as f64 * 10.0);
}
}
_ => {}
}
}
OscPattern
Pattern matching for OSC addresses.
let pattern = OscPattern::new("/synth/voice/*/cutoff");
// Matches:
// /synth/voice/1/cutoff
// /synth/voice/2/cutoff
// etc.
if pattern.matches(&msg.address) {
// Handle message
}
OscBinding
Maps OSC to patch parameters.
let bindings = vec![
OscBinding::new("/synth/cutoff", "vcf.cutoff", 0.0..10.0),
OscBinding::new("/synth/resonance", "vcf.resonance", 0.0..1.0),
];
for binding in &bindings {
if let Some(value) = binding.process(&msg) {
patch.set_parameter(&binding.target, value);
}
}
Web Audio
WebAudioProcessor
Process audio for Web Audio API.
let config = WebAudioConfig {
sample_rate: 44100.0,
channels: 2,
buffer_size: 128,
};
let processor = WebAudioProcessor::new(patch);
WebAudioWorklet
For AudioWorklet integration.
let worklet = WebAudioWorklet::new(patch);
// In worklet process()
worklet.process(&input, &mut output);
Interleaving
Web Audio uses interleaved stereo:
// Convert from separate channels
let interleaved = interleave_stereo(&left, &right);
// Convert to separate channels
let (left, right) = deinterleave_stereo(&interleaved);
Common Patterns
MIDI-Controlled Synth
let pitch_cv = Arc::new(AtomicF64::new(0.0));
let gate_cv = Arc::new(AtomicF64::new(0.0));
let vel_cv = Arc::new(AtomicF64::new(5.0));
let pitch = patch.add("pitch", ExternalInput::voct(pitch_cv.clone()));
let gate = patch.add("gate", ExternalInput::gate(gate_cv.clone()));
let velocity = patch.add("vel", ExternalInput::cv(vel_cv.clone()));
// In MIDI handler
fn handle_note_on(note: u8, vel: u8) {
pitch_cv.set((note as f64 - 60.0) / 12.0);
vel_cv.set(vel as f64 / 127.0 * 10.0);
gate_cv.set(5.0);
}
fn handle_note_off(note: u8) {
gate_cv.set(0.0);
}
OSC-Controlled Parameters
let bindings = HashMap::from([
("/filter/cutoff", vcf_cutoff_cv.clone()),
("/filter/reso", vcf_reso_cv.clone()),
("/env/attack", env_attack_cv.clone()),
]);
// In OSC handler
if let Some(cv) = bindings.get(&msg.address.as_str()) {
if let Some(OscValue::Float(v)) = msg.args.first() {
cv.set(*v as f64);
}
}
Signal Types Cheatsheet
Quick reference for Quiver’s signal conventions.
Signal Ranges
| Type | Range | Zero Point | Use |
|---|---|---|---|
| Audio | ±5V | 0V | Sound waveforms |
| CV Unipolar | 0-10V | 0V | Cutoff, rate, depth |
| CV Bipolar | ±5V | 0V | Pan, FM, bend |
| V/Oct | ±10V | 0V = C4 | Pitch |
| Gate | 0V or 5V | 0V | Sustained on/off |
| Trigger | 0V or 5V | 0V | Brief pulse |
| Clock | 0V or 5V | 0V | Timing pulses |
SignalKind Enum
pub enum SignalKind {
Audio, // ±5V AC-coupled
CvBipolar, // ±5V control
CvUnipolar, // 0-10V control
VoltPerOctave, // 1V/Oct pitch
Gate, // 0V/+5V sustained
Trigger, // 0V/+5V pulse
Clock, // Timing pulses
}
PortDef Factory Methods
| Method | Signal Kind | Default | Attenuverter |
|---|---|---|---|
::audio() | Audio | 0.0 | No |
::cv_unipolar() | CvUnipolar | 0.0 | Yes |
::cv_bipolar() | CvBipolar | 0.0 | Yes |
::voct() | VoltPerOctave | 0.0 | No |
::gate() | Gate | 0.0 | No |
::trigger() | Trigger | 0.0 | No |
::clock() | Clock | 0.0 | No |
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
| Value | Effect |
|---|---|
| -2.0 | Invert and double |
| -1.0 | Invert |
| -0.5 | Invert and halve |
| 0.0 | Silence |
| 0.5 | Half level |
| 1.0 | Unity (unchanged) |
| 2.0 | Double |
Cable Attenuation
Cable::new()
.with_attenuation(0.5) // Scale signal
.with_offset(2.0) // Add DC offset
Input Summing
Multiple cables to one input are added:
LFO1 (+2V) ─┐
├── Input receives +5V
LFO2 (+3V) ─┘
Normalled Connections
When input is unpatched, uses normalled source:
PortDef::audio().with_normalled_to("other_port")
V/Oct Reference
Complete reference for the Volt-per-Octave pitch standard.
The Standard
1 Volt = 1 Octave
$$f = f_0 \cdot 2^V$$
Where:
- $f$ = frequency in Hz
- $f_0$ = 261.63 Hz (C4 at 0V)
- $V$ = voltage
Complete Note Table
| Note | MIDI | V/Oct | Frequency |
|---|---|---|---|
| C0 | 12 | -4.000V | 16.35 Hz |
| C1 | 24 | -3.000V | 32.70 Hz |
| C2 | 36 | -2.000V | 65.41 Hz |
| C3 | 48 | -1.000V | 130.81 Hz |
| C4 | 60 | 0.000V | 261.63 Hz |
| C5 | 72 | +1.000V | 523.25 Hz |
| C6 | 84 | +2.000V | 1046.50 Hz |
| C7 | 96 | +3.000V | 2093.00 Hz |
| C8 | 108 | +4.000V | 4186.01 Hz |
Chromatic Scale (Octave 4)
| Note | MIDI | V/Oct | Frequency |
|---|---|---|---|
| C4 | 60 | +0.000V | 261.63 Hz |
| C#4 | 61 | +0.083V | 277.18 Hz |
| D4 | 62 | +0.167V | 293.66 Hz |
| D#4 | 63 | +0.250V | 311.13 Hz |
| E4 | 64 | +0.333V | 329.63 Hz |
| F4 | 65 | +0.417V | 349.23 Hz |
| F#4 | 66 | +0.500V | 369.99 Hz |
| G4 | 67 | +0.583V | 392.00 Hz |
| G#4 | 68 | +0.667V | 415.30 Hz |
| A4 | 69 | +0.750V | 440.00 Hz |
| A#4 | 70 | +0.833V | 466.16 Hz |
| B4 | 71 | +0.917V | 493.88 Hz |
Intervals
| Interval | Semitones | Voltage |
|---|---|---|
| Unison | 0 | 0.000V |
| Minor 2nd | 1 | 0.083V |
| Major 2nd | 2 | 0.167V |
| Minor 3rd | 3 | 0.250V |
| Major 3rd | 4 | 0.333V |
| Perfect 4th | 5 | 0.417V |
| Tritone | 6 | 0.500V |
| Perfect 5th | 7 | 0.583V |
| Minor 6th | 8 | 0.667V |
| Major 6th | 9 | 0.750V |
| Minor 7th | 10 | 0.833V |
| Major 7th | 11 | 0.917V |
| Octave | 12 | 1.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
| Offset | Effect |
|---|---|
| +1V | Up one octave |
| -1V | Down one octave |
| +0.583V | Up a fifth |
| +0.333V | Up a major third |
| +0.01V | ~12 cents (detune) |
Tracking Errors
Real analog oscillators have tracking errors:
| Error Type | Typical Amount |
|---|---|
| Scale error | ±1-5% |
| Offset error | ±10-50mV |
| Temperature drift | 1-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
| Category | Description |
|---|---|
Classic | Iconic synth sounds |
Bass | Bass patches |
Lead | Lead/solo sounds |
Pad | Sustained pad sounds |
Percussion | Drums and percussion |
Effect | Effects and textures |
SoundDesign | Experimental sounds |
Tutorial | Learning 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