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.
//!
//! Run with: cargo run --example quick_taste
use quiver::prelude::*;
fn main() {
// Create a patch at CD-quality sample rate
let mut patch = Patch::new(44100.0);
// Add an oscillator and output
let vco = patch.add("vco", Vco::new(44100.0));
let output = patch.add("out", StereoOutput::new());
// Connect the sawtooth wave to both channels
patch.connect(vco.out("saw"), output.in_("left")).unwrap();
patch.connect(vco.out("saw"), output.in_("right")).unwrap();
// Compile the patch for processing
patch.set_output(output.id());
patch.compile().unwrap();
// Generate one second of audio
let mut samples = Vec::new();
for _ in 0..44100 {
let (left, _right) = patch.tick();
samples.push(left);
}
// Report the results
let peak = samples.iter().map(|s| s.abs()).fold(0.0_f64, f64::max);
println!("Generated {} samples", samples.len());
println!("Peak amplitude: {:.2}V", peak);
}
What You’ll Learn
This documentation guides you from first patch to advanced synthesis:
- 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.70+ (2021 edition)
- Cargo (comes with Rust)
Verify your installation:
rustc --version
cargo --version
Adding Quiver to Your Project
As a Dependency
Add to your Cargo.toml:
[dependencies]
quiver = { git = "https://github.com/alexnodeland/quiver" }
Or with specific features:
[dependencies]
quiver = { git = "https://github.com/alexnodeland/quiver", features = ["simd"] }
Available Features
| 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) |
Feature Tiers
Quiver supports three tiers for different environments:
Tier 1: Core Only (default-features = false)
For bare-metal embedded systems without heap allocation:
[dependencies]
quiver = { git = "https://github.com/alexnodeland/quiver", default-features = false }
Includes all core DSP modules: oscillators, filters, envelopes, amplifiers, mixers, utilities, logic modules, analog modeling, polyphony, and the patch graph.
Tier 2: With Alloc (features = ["alloc"])
For WASM web apps and embedded systems with heap:
[dependencies]
quiver = { git = "https://github.com/alexnodeland/quiver", default-features = false, features = ["alloc"] }
Adds:
- Serialization - JSON save/load for patches (
PatchDef,ModuleDef,CableDef) - Presets - Ready-to-use patch presets (
ClassicPresets,PresetLibrary) - I/O Modules - External inputs/outputs, MIDI state (
AtomicF64,MidiState)
Tier 3: Full Std (default)
For desktop applications:
[dependencies]
quiver = { git = "https://github.com/alexnodeland/quiver" }
Adds:
- Extended I/O - OSC protocol, Web Audio interfaces
- Visual Tools - Scope, Spectrum Analyzer, Level Meter, Automation Recorder
- MDK - Module Development Kit for creating custom modules
Feature Matrix
| 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.
//!
//! 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.
//!
//! Run with: cargo run --example tutorial_subtractive
use quiver::prelude::*;
fn main() {
let sample_rate = 44100.0;
let mut patch = Patch::new(sample_rate);
// The oscillator: source of harmonics
let vco = patch.add("vco", Vco::new(sample_rate));
// The filter: subtracts harmonics
let vcf = patch.add("vcf", Svf::new(sample_rate));
// Output stage
let output = patch.add("output", StereoOutput::new());
// Offset module to set filter cutoff (in CV range)
// 5V corresponds to a medium-high cutoff frequency
let cutoff = patch.add("cutoff", Offset::new(5.0));
// Connect: Saw wave → Filter → Output
patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
patch.connect(cutoff.out("out"), vcf.in_("cutoff")).unwrap();
patch.connect(vcf.out("lp"), output.in_("left")).unwrap();
patch.set_output(output.id());
patch.compile().unwrap();
// Generate samples and analyze harmonic content
println!("=== Subtractive Synthesis Demo ===\n");
// Collect one period of audio (assuming ~261Hz C4)
let period_samples = (sample_rate / 261.63) as usize;
let mut samples: Vec<f64> = Vec::new();
for _ in 0..period_samples * 10 {
let (left, _) = patch.tick();
samples.push(left);
}
// Analyze the filtered output
let peak = samples.iter().map(|s| s.abs()).fold(0.0_f64, f64::max);
let rms = (samples.iter().map(|s| s * s).sum::<f64>() / samples.len() as f64).sqrt();
println!("Sawtooth → Lowpass Filter");
println!(" Peak amplitude: {:.2}V", peak);
println!(" RMS level: {:.2}V", rms);
println!(" Samples generated: {}", samples.len());
// Compare with unfiltered saw
let mut raw_patch = Patch::new(sample_rate);
let raw_vco = raw_patch.add("vco", Vco::new(sample_rate));
let raw_out = raw_patch.add("output", StereoOutput::new());
raw_patch
.connect(raw_vco.out("saw"), raw_out.in_("left"))
.unwrap();
raw_patch.set_output(raw_out.id());
raw_patch.compile().unwrap();
let mut raw_samples: Vec<f64> = Vec::new();
for _ in 0..period_samples * 10 {
let (left, _) = raw_patch.tick();
raw_samples.push(left);
}
let raw_peak = raw_samples.iter().map(|s| s.abs()).fold(0.0_f64, f64::max);
let raw_rms =
(raw_samples.iter().map(|s| s * s).sum::<f64>() / raw_samples.len() as f64).sqrt();
println!("\nRaw Sawtooth (unfiltered)");
println!(" Peak amplitude: {:.2}V", raw_peak);
println!(" RMS level: {:.2}V", raw_rms);
println!("\nThe filter has smoothed the waveform by removing high harmonics.");
println!("Notice the lower RMS - less high-frequency energy means a softer sound.");
}
Understanding the Filter
The state-variable filter (SVF) in Quiver simultaneously outputs:
- Lowpass — removes high frequencies
- Bandpass — isolates a frequency band
- Highpass — removes low frequencies
- Notch — removes a specific band
graph TB
subgraph "SVF Outputs"
IN[Audio In] --> SVF[State Variable<br/>Filter]
SVF --> LP[Lowpass]
SVF --> BP[Bandpass]
SVF --> HP[Highpass]
SVF --> NOTCH[Notch]
end
Filter Response
The lowpass filter attenuates frequencies above the cutoff:
$$H(f) = \frac{1}{\sqrt{1 + (f/f_c)^{2n}}}$$
Where $f_c$ is cutoff frequency and $n$ is filter order.
Quiver’s SVF is 12dB/octave (2-pole), meaning frequencies one octave above cutoff are reduced by 12dB.
Resonance
Resonance (Q) boosts frequencies near cutoff:
graph LR
subgraph "Resonance Effect"
FLAT[Low Q<br/>Flat response]
PEAK[High Q<br/>Resonant peak]
end
At maximum resonance, the filter self-oscillates, becoming a sine wave generator.
Experimenting
- 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.
//!
//! Run with: cargo run --example tutorial_envelope
use quiver::prelude::*;
use std::sync::Arc;
fn main() {
let sample_rate = 44100.0;
let mut patch = Patch::new(sample_rate);
// Gate control - simulates key press
let gate_cv = Arc::new(AtomicF64::new(0.0));
let gate = patch.add("gate", ExternalInput::gate(Arc::clone(&gate_cv)));
// Sound source
let vco = patch.add("vco", Vco::new(sample_rate));
// ADSR envelope generator
let env = patch.add("env", Adsr::new(sample_rate));
// Amplifier controlled by envelope
let vca = patch.add("vca", Vca::new());
// Output
let output = patch.add("output", StereoOutput::new());
// Connections
patch.connect(gate.out("out"), env.in_("gate")).unwrap();
patch.connect(vco.out("saw"), vca.in_("in")).unwrap();
patch.connect(env.out("env"), vca.in_("cv")).unwrap();
patch.connect(vca.out("out"), output.in_("left")).unwrap();
patch.set_output(output.id());
patch.compile().unwrap();
println!("=== ADSR Envelope Demo ===\n");
// Helper to get envelope level
fn run_samples(patch: &mut Patch, n: usize) -> f64 {
let mut last = 0.0;
for _ in 0..n {
let (left, _) = patch.tick();
last = left.abs();
}
last
}
// Start with gate off
println!("Initial state (gate off):");
let level = run_samples(&mut patch, 100);
println!(" Envelope level: {:.3}V\n", level);
// Gate ON - trigger attack
println!("Gate ON - Attack phase begins");
gate_cv.set(5.0);
// Sample the attack
for ms in [10, 25, 50, 100, 200] {
let samples = (sample_rate * ms as f64 / 1000.0) as usize;
let level = run_samples(&mut patch, samples);
println!(" {}ms: level = {:.2}V", ms, level * 5.0); // scale for display
}
// Let it reach sustain
println!("\nDecay → Sustain:");
let level = run_samples(&mut patch, (sample_rate * 0.5) as usize);
println!(" Sustain level: {:.2}V\n", level * 5.0);
// Gate OFF - trigger release
println!("Gate OFF - Release phase begins");
gate_cv.set(0.0);
for ms in [50, 100, 200, 500] {
let samples = (sample_rate * ms as f64 / 1000.0) as usize;
let level = run_samples(&mut patch, samples);
println!(" +{}ms: level = {:.3}V", ms, level * 5.0);
}
println!("\nThe envelope has completed its cycle.");
println!("Attack→Decay→Sustain (while held) →Release (when released)");
}
Envelope as Modulation Source
The envelope doesn’t just control volume. Route it to:
flowchart TD
ADSR[ADSR Envelope]
ADSR -->|brightness| VCF[Filter Cutoff]
ADSR -->|volume| VCA[Amplifier]
ADSR -->|depth| FM[FM Amount]
ADSR -->|color| PWM[Pulse Width]
Filter Envelope
Routing envelope to filter creates the classic “brightness sweep”:
- Plucky bass: Fast attack, fast decay, low sustain
- Brass stab: Medium attack, fast decay, medium sustain
- String pad: Slow attack, slow decay, high sustain
Dual Envelope Routing
Different amounts to different destinations:
| 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.
//!
//! Run with: cargo run --example tutorial_filter_mod
use quiver::prelude::*;
fn main() {
let sample_rate = 44100.0;
let mut patch = Patch::new(sample_rate);
// Sound source - sawtooth oscillator
let vco = patch.add("vco", Vco::new(sample_rate));
// LFO for modulation (runs at sub-audio rate)
let lfo = patch.add("lfo", Lfo::new(sample_rate));
// Filter - we'll modulate its cutoff
let vcf = patch.add("vcf", Svf::new(sample_rate));
// Base cutoff offset
let cutoff_base = patch.add("cutoff_base", Offset::new(3.0));
// Output
let output = patch.add("output", StereoOutput::new());
// Audio path: VCO → Filter → Output
patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
patch.connect(vcf.out("lp"), output.in_("left")).unwrap();
// Modulation: LFO → Filter cutoff (with base offset)
patch
.connect(cutoff_base.out("out"), vcf.in_("cutoff"))
.unwrap();
patch.connect(lfo.out("sin"), vcf.in_("fm")).unwrap();
patch.set_output(output.id());
patch.compile().unwrap();
println!("=== Filter Modulation Demo ===\n");
println!("LFO modulating filter cutoff creates the classic 'wobble' effect.\n");
// Generate 2 seconds of audio to hear multiple LFO cycles
let duration = 2.0;
let total_samples = (sample_rate * duration) as usize;
// Track the signal envelope over time
let block_size = (sample_rate / 10.0) as usize; // 100ms blocks
let mut time = 0.0;
println!("Time(s) | Peak Level | Character");
println!("---------|------------|----------");
for block in 0..(total_samples / block_size) {
let mut peak = 0.0_f64;
for _ in 0..block_size {
let (left, _) = patch.tick();
peak = peak.max(left.abs());
}
// Describe the sound character based on peak
let character = if peak > 4.0 {
"Bright (filter open)"
} else if peak > 2.0 {
"Medium"
} else {
"Dark (filter closed)"
};
if block % 5 == 0 {
println!("{:7.2} | {:10.2}V | {}", time, peak, character);
}
time += block_size as f64 / sample_rate;
}
println!("\nThe LFO creates a periodic sweep of the filter,");
println!("cycling between bright (open) and dark (closed) states.");
println!("\nTry different LFO waveforms:");
println!(" - sin: smooth, natural sweep");
println!(" - tri: linear ramp up and down");
println!(" - saw: slow rise, fast drop");
println!(" - sqr: instant toggle between states");
}
Modulation Depth and Attenuverters
The amount of modulation matters:
| 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.
//!
//! Run with: cargo run --example tutorial_sequenced_bass
use quiver::prelude::*;
fn main() {
let sample_rate = 44100.0;
let mut patch = Patch::new(sample_rate);
// Master clock - sets the tempo
let clock = patch.add("clock", Clock::new(sample_rate));
// Step sequencer - stores our bassline pattern
let seq = patch.add("seq", StepSequencer::new());
// Bass voice: VCO → VCF → VCA
let vco = patch.add("vco", Vco::new(sample_rate));
let vcf = patch.add("vcf", Svf::new(sample_rate));
let vca = patch.add("vca", Vca::new());
let env = patch.add("env", Adsr::new(sample_rate));
// Output
let output = patch.add("output", StereoOutput::new());
// Clock → Sequencer
patch.connect(clock.out("div_8"), seq.in_("clock")).unwrap();
// Sequencer → Voice
patch.connect(seq.out("cv"), vco.in_("voct")).unwrap();
patch.connect(seq.out("gate"), env.in_("gate")).unwrap();
// Audio path
patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
patch.connect(vcf.out("lp"), vca.in_("in")).unwrap();
patch.connect(vca.out("out"), output.in_("left")).unwrap();
patch.connect(vca.out("out"), output.in_("right")).unwrap();
// Envelope → Filter & VCA
patch.connect(env.out("env"), vcf.in_("cutoff")).unwrap();
patch.connect(env.out("env"), vca.in_("cv")).unwrap();
patch.set_output(output.id());
patch.compile().unwrap();
println!("=== Sequenced Bass Demo ===\n");
// The sequencer has 8 steps with default values
// In a real application, you'd set the step CVs programmatically
// Convert MIDI note to V/Oct
fn midi_to_voct(note: u8) -> f64 {
(note as f64 - 60.0) / 12.0
}
// Our bassline: C3, D3, rest, G2, C3, rest, E3, D3
let pattern = [
(48, true), // C3
(50, true), // D3
(0, false), // rest
(43, true), // G2
(48, true), // C3
(0, false), // rest
(52, true), // E3
(50, true), // D3
];
println!("Bassline pattern:");
for (i, (note, active)) in pattern.iter().enumerate() {
if *active {
let voct = midi_to_voct(*note);
let note_name = match note % 12 {
0 => "C",
1 => "C#",
2 => "D",
3 => "D#",
4 => "E",
5 => "F",
6 => "F#",
7 => "G",
8 => "G#",
9 => "A",
10 => "A#",
11 => "B",
_ => "?",
};
let octave = (note / 12) - 1;
println!(" Step {}: {}{} ({:.3}V)", i + 1, note_name, octave, voct);
} else {
println!(" Step {}: rest", i + 1);
}
}
println!("\nRunning sequencer for 2 seconds...\n");
// Run the patch
let total_samples = (sample_rate * 2.0) as usize;
let step_samples = total_samples / 16; // ~8 steps at default tempo
for step in 0..16 {
let mut peak = 0.0_f64;
for _ in 0..step_samples {
let (left, _) = patch.tick();
peak = peak.max(left.abs());
}
let step_num = (step % 8) + 1;
let bar = "█".repeat((peak * 10.0) as usize);
println!("Step {}: {:5.2}V |{}", step_num, peak, bar);
}
println!("\nThe sequencer cycles through the pattern,");
println!("triggering the envelope on each gated step.");
}
Clock Divisions
The clock module provides multiple time divisions:
graph TB
MASTER[Master Clock<br/>120 BPM] --> D1[1/1<br/>Whole notes]
MASTER --> D2[1/2<br/>Half notes]
MASTER --> D4[1/4<br/>Quarter notes]
MASTER --> D8[1/8<br/>Eighth notes]
MASTER --> D16[1/16<br/>Sixteenth notes]
For a bassline at 120 BPM:
- 1/8 notes = 4 Hz (classic house tempo)
- 1/16 notes = 8 Hz (driving techno)
Filter Envelope Relationship
The key to punchy bass is the filter envelope:
Attack: Fast (5ms)
Decay: Medium (100-200ms)
Sustain: Low (20-40%)
Release: Quick (50-100ms)
This creates the characteristic “pluck” where brightness fades quickly.
Accent and Dynamics
Real sequences have accents—emphasized notes. Implement with velocity:
sequenceDiagram
participant SEQ as Sequencer
participant ENV as Envelope
SEQ->>ENV: Step 1 (normal)
Note over ENV: Attack → Sustain
SEQ->>ENV: Step 2 (accented)
Note over ENV: Attack → Higher peak<br/>→ Sustain
Classic Patterns
House Bass
Step: 1 2 3 4 5 6 7 8
Note: C - C - C - C C
The off-beat creates the groove.
Acid (TB-303 Style)
Step: 1 2 3 4 5 6 7 8
Note: C C D - F - D C
Acc: X X
Slide: → →
Accents and slides define the style.
Minimal Techno
Step: 1 2 3 4 5 6 7 8
Note: C - - - C - - -
Space and repetition create hypnotic effect.
Going Further
- Add slide/portamento with
SlewLimiter - Randomize steps with
BernoulliGate - Quantize to scale with
Quantizer - Layer with detuned second VCO
Next: FM Synthesis Basics
FM Synthesis Basics
Frequency Modulation (FM) synthesis creates complex timbres by modulating one oscillator’s frequency with another. It’s the technology behind the DX7 and countless digital synths.
flowchart LR
MOD[Modulator<br/>Oscillator] -->|FM| CAR[Carrier<br/>Oscillator]
CAR --> OUT[Output]
style MOD fill:#f9a826,color:#000
style CAR fill:#4a9eff,color:#fff
The Mathematics
In FM synthesis, the carrier frequency is modulated by the modulator:
$$y(t) = A \sin(2\pi f_c t + I \sin(2\pi f_m t))$$
Where:
- $f_c$ = carrier frequency (the pitch you hear)
- $f_m$ = modulator frequency
- $I$ = modulation index (depth)
- $A$ = amplitude
The modulation index controls harmonic richness:
| 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.
//!
//! Run with: cargo run --example tutorial_fm
use quiver::prelude::*;
fn main() {
let sample_rate = 44100.0;
let mut patch = Patch::new(sample_rate);
// Carrier oscillator - this is what we hear
let carrier = patch.add("carrier", Vco::new(sample_rate));
// Modulator oscillator - this modulates the carrier's frequency
let modulator = patch.add("modulator", Vco::new(sample_rate));
// Modulation index control (depth of FM effect)
let mod_depth = patch.add("mod_depth", Attenuverter::new());
// Output
let output = patch.add("output", StereoOutput::new());
// FM connection: modulator → carrier's FM input
patch
.connect(modulator.out("sin"), mod_depth.in_("in"))
.unwrap();
patch
.connect(mod_depth.out("out"), carrier.in_("fm"))
.unwrap();
// Carrier to output (using sine for pure FM demonstration)
patch
.connect(carrier.out("sin"), output.in_("left"))
.unwrap();
patch
.connect(carrier.out("sin"), output.in_("right"))
.unwrap();
patch.set_output(output.id());
patch.compile().unwrap();
println!("=== FM Synthesis Demo ===\n");
println!("Two oscillators: Carrier (audible) + Modulator (creates harmonics)\n");
// Generate samples at different modulation depths
let samples_per_test = (sample_rate * 0.5) as usize;
// Test different modulation indices
for (name, ratio, depth) in [
("Pure carrier (no FM)", 1.0, 0.0),
("Subtle FM (index ~1)", 1.0, 0.5),
("Medium FM (index ~3)", 1.0, 1.5),
("Heavy FM (index ~5)", 1.0, 2.5),
("Bell (1:√2 ratio)", 1.414, 2.0),
("Metallic (1:3.5 ratio)", 3.5, 2.0),
] {
// Reset and reconfigure
let mut test_patch = Patch::new(sample_rate);
let carrier = test_patch.add("carrier", Vco::new(sample_rate));
let modulator = test_patch.add("modulator", Vco::new(sample_rate));
let mod_depth_node = test_patch.add("mod_depth", Attenuverter::new());
let ratio_mult = test_patch.add("ratio", Attenuverter::new()); // Scale modulator pitch
let output = test_patch.add("output", StereoOutput::new());
// Set up FM with the given parameters
test_patch
.connect(modulator.out("sin"), mod_depth_node.in_("in"))
.unwrap();
test_patch
.connect(mod_depth_node.out("out"), carrier.in_("fm"))
.unwrap();
test_patch
.connect(carrier.out("sin"), output.in_("left"))
.unwrap();
test_patch.set_output(output.id());
test_patch.compile().unwrap();
// Generate samples
let mut peak = 0.0_f64;
let mut zero_crossings = 0;
let mut last_sign = 0.0_f64;
for i in 0..samples_per_test {
let (left, _) = test_patch.tick();
peak = peak.max(left.abs());
// Count zero crossings (rough measure of harmonic content)
if i > 0 {
let current_sign = if left >= 0.0 { 1.0 } else { -1.0 };
if current_sign != last_sign {
zero_crossings += 1;
}
last_sign = current_sign;
}
}
// Zero crossing rate indicates harmonic complexity
let zcr = zero_crossings as f64 / (samples_per_test as f64 / sample_rate);
println!("{}", name);
println!(" C:M ratio = 1:{:.3}, mod depth = {:.1}", ratio, depth);
println!(" Peak: {:.2}V, Zero-crossing rate: {:.0} Hz", peak, zcr);
println!();
}
println!("FM synthesis creates complex timbres from simple oscillators.");
println!("The carrier:modulator ratio determines harmonic vs inharmonic sound.");
println!("The modulation index (depth) controls brightness and complexity.");
}
Sideband Theory
FM creates sidebands around the carrier frequency:
$$f_{sidebands} = f_c \pm n \cdot f_m$$
Where $n = 1, 2, 3, …$
▲
│ ▲
▲ │ │ ▲
│ │ │ │
───┴───┴────┴───┴───
-2fm -fm fc +fm +2fm
The modulation index determines how many sidebands have significant amplitude (roughly $I + 1$ sidebands on each side).
Envelope the Index
The key to expressive FM is modulating the modulation index over time:
flowchart LR
ENV[Envelope] -->|index| FM((FM<br/>Amount))
MOD[Modulator] --> FM
FM --> CAR[Carrier]
A decaying envelope creates the characteristic “bright attack, mellow sustain” of electric pianos.
Classic FM Sounds
Electric Piano (DX7 Style)
Carrier:Modulator = 1:1
Index envelope: Fast attack, medium decay
Starting index: ~5
Ending index: ~1
Brass
Carrier:Modulator = 1:1
Index envelope: Slow attack
Starting index: 2
Peak index: 8
Bell
Carrier:Modulator = 1:1.414 (√2)
Index: 8-10 (constant)
Long release envelope
Bass
Carrier:Modulator = 1:2
Fast index decay
Heavy carrier filtering
FM vs Subtractive
| 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.
//!
//! 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);
}
println!("\nPolyphony enables expressive keyboard playing and chord voicings.");
}
Per-Voice Signals
Each voice receives its own:
- V/Oct pitch — from the played note
- Gate — high while key held
- Trigger — pulse at note start
- Velocity — key strike strength
flowchart LR
VA[Voice Allocator]
VA -->|voct| VCO[VCO]
VA -->|gate| ENV[ADSR]
VA -->|velocity| VCA[Velocity VCA]
Unison and Detune
For thicker sounds, stack multiple oscillators per voice:
let config = UnisonConfig::new(3) // 3 oscillators per voice
.with_detune(0.1) // Slight detune between them
.with_spread(0.5); // Stereo spread
The slight detuning creates a chorus-like richness.
MIDI Note to V/Oct
Quiver uses the standard conversion:
$$V_{oct} = \frac{\text{MIDI} - 60}{12}$$
| MIDI 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 all the ways to connect modules in Quiver, from basic patching to advanced cable configuration.
Basic Connection
The fundamental operation:
patch.connect(source.out("output_name"), dest.in_("input_name"))?;
Note: Use
in_()becauseinis a Rust reserved word.
Finding Port Names
Check the module’s PortSpec:
let vco = Vco::new(44100.0);
let spec = vco.port_spec();
println!("Inputs: {:?}", spec.inputs.keys().collect::<Vec<_>>());
println!("Outputs: {:?}", spec.outputs.keys().collect::<Vec<_>>());
Common port names:
| Module | Inputs | Outputs |
|---|---|---|
| Vco | voct, fm, pw, sync | sin, tri, saw, sqr |
| Svf | in, cutoff, resonance, fm | lp, bp, hp, notch |
| Adsr | gate, attack, decay, sustain, release | env |
| Vca | in, cv | out |
Connection with Attenuation
Scale the signal strength:
patch.connect_with(
lfo.out("sin"),
vcf.in_("cutoff"),
Cable::new().with_attenuation(0.5), // 50% strength
)?;
Attenuation range: -2.0 to +2.0
- 1.0 = unity (no change)
- 0.5 = half strength
- -1.0 = inverted
- 2.0 = doubled (with clipping risk)
Connection with Offset
Add a DC offset to the signal:
patch.connect_with(
lfo.out("sin"),
vcf.in_("cutoff"),
Cable::new()
.with_attenuation(0.3)
.with_offset(5.0), // Center at 5V
)?;
This shifts the LFO’s ±5V swing to oscillate around 5V.
Multiple Outputs (Mult)
One output can feed multiple inputs:
// Same gate triggers multiple envelopes
patch.connect(gate.out("out"), env1.in_("gate"))?;
patch.connect(gate.out("out"), env2.in_("gate"))?;
patch.connect(gate.out("out"), env3.in_("gate"))?;
Quiver handles the signal distribution automatically.
Multiple Inputs (Summing)
Multiple cables to one input are summed:
// Two LFOs combined on filter cutoff
patch.connect(lfo1.out("sin"), vcf.in_("cutoff"))?;
patch.connect(lfo2.out("tri"), vcf.in_("cutoff"))?;
// Result: filter cutoff receives lfo1 + lfo2
This models analog behavior where CVs mix at the input.
Validation Modes
Control how signal type mismatches are handled:
// Strict: error on mismatch
patch.set_validation_mode(ValidationMode::Strict);
// Warn: log warning but allow
patch.set_validation_mode(ValidationMode::Warn);
// None: no checking
patch.set_validation_mode(ValidationMode::None);
Default is Warn, which helps catch mistakes without blocking experimentation.
Disconnecting
Remove a specific connection:
let cable_id = patch.connect(vco.out("saw"), vcf.in_("in"))?;
patch.disconnect(cable_id);
Or disconnect by ports:
patch.disconnect_port(vcf.in_("in")); // Remove all cables to this input
Error Handling
Connection can fail for several reasons:
match patch.connect(a.out("x"), b.in_("y")) {
Ok(cable_id) => println!("Connected: {:?}", cable_id),
Err(PatchError::PortNotFound(port)) => {
println!("Port '{}' doesn't exist", port);
}
Err(PatchError::CycleDetected) => {
println!("Would create a feedback loop");
}
Err(e) => println!("Error: {:?}", e),
}
Checking Connections
Query existing cables:
// All cables in patch
for cable in patch.cables() {
println!("{:?} → {:?}", cable.from, cable.to);
}
// Cables to a specific input
for cable in patch.cables_to(vcf.in_("cutoff")) {
println!("Modulated by: {:?}", cable.from);
}
Best Practices
- Name modules clearly:
"filter_lfo"not"lfo2" - Use validation mode Warn during development
- Check port specs if unsure about names
- Apply attenuation rather than amplification to avoid clipping
Use External MIDI
Connect your Quiver patches to MIDI keyboards, controllers, and DAWs.
The AtomicF64 Bridge
MIDI events arrive on a separate thread. Use AtomicF64 for thread-safe communication:
use std::sync::Arc;
use quiver::prelude::*;
// Create shared atomic values
let pitch = Arc::new(AtomicF64::new(0.0)); // V/Oct
let gate = Arc::new(AtomicF64::new(0.0)); // Gate signal
let mod_wheel = Arc::new(AtomicF64::new(0.0)); // CC1
// Clone for MIDI thread
let pitch_midi = Arc::clone(&pitch);
let gate_midi = Arc::clone(&gate);
ExternalInput Module
Inject atomic values into your patch:
let pitch_in = patch.add("pitch", ExternalInput::voct(Arc::clone(&pitch)));
let gate_in = patch.add("gate", ExternalInput::gate(Arc::clone(&gate)));
patch.connect(pitch_in.out("out"), vco.in_("voct"))?;
patch.connect(gate_in.out("out"), env.in_("gate"))?;
ExternalInput variants:
| Factory 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
For more complete MIDI handling:
let midi_state = MidiState::new();
// In MIDI callback:
midi_state.note_on(60, 100); // Note 60, velocity 100
midi_state.note_off(60);
midi_state.control_change(1, 64); // Mod wheel to 50%
// Read current state:
let current_voct = midi_state.voct();
let current_gate = midi_state.gate();
let mod_value = midi_state.cc(1);
Example: Complete MIDI Integration
//! How-To: MIDI Input Integration
//!
//! Demonstrates connecting external MIDI to a Quiver patch using AtomicF64
//! for thread-safe communication between MIDI and audio threads.
//!
//! Run with: cargo run --example howto_midi
use quiver::prelude::*;
use std::sync::Arc;
fn main() {
let sample_rate = 44100.0;
// Thread-safe communication channels
let pitch_cv = Arc::new(AtomicF64::new(0.0)); // V/Oct
let gate_cv = Arc::new(AtomicF64::new(0.0)); // Gate
let velocity_cv = Arc::new(AtomicF64::new(5.0)); // Velocity (0-10V)
let mod_wheel_cv = Arc::new(AtomicF64::new(0.0)); // CC1 modulation
// Create patch
let mut patch = Patch::new(sample_rate);
// External inputs
let pitch = patch.add("midi_pitch", ExternalInput::voct(Arc::clone(&pitch_cv)));
let gate = patch.add("midi_gate", ExternalInput::gate(Arc::clone(&gate_cv)));
let velocity = patch.add("midi_vel", ExternalInput::cv(Arc::clone(&velocity_cv)));
let mod_wheel = patch.add("mod_wheel", ExternalInput::cv(Arc::clone(&mod_wheel_cv)));
// Synth voice
let vco = patch.add("vco", Vco::new(sample_rate));
let vcf = patch.add("vcf", Svf::new(sample_rate));
let vca = patch.add("vca", Vca::new());
let env = patch.add("env", Adsr::new(sample_rate));
let output = patch.add("output", StereoOutput::new());
// MIDI → synth connections
patch.connect(pitch.out("out"), vco.in_("voct")).unwrap();
patch.connect(gate.out("out"), env.in_("gate")).unwrap();
// Audio chain
patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
patch.connect(vcf.out("lp"), vca.in_("in")).unwrap();
patch.connect(vca.out("out"), output.in_("left")).unwrap();
patch.connect(vca.out("out"), output.in_("right")).unwrap();
// Modulation routing
patch.connect(env.out("env"), vcf.in_("cutoff")).unwrap();
patch.connect(env.out("env"), vca.in_("cv")).unwrap();
patch.connect(mod_wheel.out("out"), vcf.in_("fm")).unwrap(); // Mod wheel → filter
patch.set_output(output.id());
patch.compile().unwrap();
println!("=== MIDI Integration Demo ===\n");
// Simulate MIDI events (in real app, these come from MIDI callback)
fn midi_note_to_voct(note: u8) -> f64 {
(note as f64 - 60.0) / 12.0
}
fn midi_velocity_to_cv(velocity: u8) -> f64 {
velocity as f64 / 127.0 * 10.0
}
fn midi_cc_to_cv(value: u8) -> f64 {
value as f64 / 127.0 * 10.0
}
// Simulate playing a C4 note
println!("Simulating MIDI Note On: C4 (60), velocity 100");
pitch_cv.set(midi_note_to_voct(60));
velocity_cv.set(midi_velocity_to_cv(100));
gate_cv.set(5.0); // Gate high
// Process some samples during note
let attack_samples = (sample_rate * 0.3) as usize;
for _ in 0..attack_samples {
patch.tick();
}
println!(" Attack phase processed ({} samples)", attack_samples);
// Simulate mod wheel movement
println!("\nSimulating CC1 (Mod Wheel): 64");
mod_wheel_cv.set(midi_cc_to_cv(64));
// More processing
for _ in 0..(sample_rate * 0.2) as usize {
patch.tick();
}
// Simulate note off
println!("\nSimulating MIDI Note Off");
gate_cv.set(0.0); // Gate low
// Process release
let release_samples = (sample_rate * 0.5) as usize;
for _ in 0..release_samples {
patch.tick();
}
println!(" Release phase processed ({} samples)", release_samples);
// Play a chord (demonstrating polyphony would need PolyPatch)
println!("\n--- Playing ascending notes ---");
for (note, name) in [(60, "C4"), (64, "E4"), (67, "G4"), (72, "C5")] {
// Note on
pitch_cv.set(midi_note_to_voct(note));
gate_cv.set(5.0);
// Play for 200ms
let mut peak = 0.0_f64;
for _ in 0..(sample_rate * 0.2) as usize {
let (left, _) = patch.tick();
peak = peak.max(left.abs());
}
// Note off
gate_cv.set(0.0);
for _ in 0..(sample_rate * 0.1) as usize {
patch.tick();
}
println!(
" {} (MIDI {}): V/Oct = {:.3}V, peak = {:.2}V",
name,
note,
midi_note_to_voct(note),
peak
);
}
println!("\nMIDI integration complete.");
println!("In a real application:");
println!(" 1. Create AtomicF64 values for each MIDI parameter");
println!(" 2. Update them from your MIDI callback");
println!(" 3. The audio thread reads the latest values each tick");
}
Gate vs Trigger
sequenceDiagram
participant K as Keyboard
participant G as Gate
participant T as Trigger
K->>G: Key Down
G->>G: Goes HIGH (+5V)
T->>T: Brief pulse (5ms)
Note over G: Stays HIGH while held
K->>G: Key Up
G->>G: Goes LOW (0V)
- Gate: Stays high while key held (for sustain)
- Trigger: Brief pulse at note start (for percussion)
Velocity Mapping
Convert MIDI velocity (0-127) to CV:
fn velocity_to_cv(velocity: u8) -> f64 {
velocity as f64 / 127.0 * 10.0 // 0-10V range
}
Route to VCA for dynamics:
let velocity_in = patch.add("vel", ExternalInput::cv(vel_atomic));
patch.connect(velocity_in.out("out"), vca.in_("cv"))?;
Pitch Bend
Pitch bend is typically ±2 semitones:
fn pitch_bend_to_voct(bend: i16) -> f64 {
// bend: -8192 to +8191
// Result: ±2 semitones = ±(2/12) V = ±0.167V
(bend as f64 / 8192.0) * (2.0 / 12.0)
}
Sum with note pitch:
let total_pitch = note_voct + bend_voct;
pitch_atomic.set(total_pitch);
Thread Safety Notes
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"],
"modules": [
{
"name": "vco",
"module_type": "vco",
"position": [100, 200],
"state": null
}
],
"cables": [
{
"from": "vco.saw",
"to": "vcf.in",
"attenuation": 1.0
}
],
"parameters": {}
}
Loading Patches
Reconstruct a patch from JSON:
// Parse JSON
let def = PatchDef::from_json(&json_string)?;
// Create module registry
let registry = ModuleRegistry::new();
// Rebuild patch
let patch = Patch::from_def(&def, ®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 them:
registry.register("my_module", |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
Add descriptive information:
let mut def = patch.to_def("Fat Bass");
def.author = Some("Sound Designer".to_string());
def.description = Some("Classic Moog-style bass with filter sweep".to_string());
def.tags = vec!["bass".into(), "moog".into(), "classic".into()];
Versioning
The version field enables migration:
let def = PatchDef::from_json(&json)?;
match def.version {
1 => { /* current format */ }
0 => { /* legacy format - migrate */ }
_ => { /* unknown version */ }
}
Preset Library
Use the built-in preset system:
let library = PresetLibrary::new();
// List all presets
for preset in library.list() {
println!("{}: {}", preset.name, preset.description);
}
// Get presets by category
let basses = library.by_category(PresetCategory::Bass);
// Search by tag
let acid = library.search_tags(&["acid", "303"]);
// Load a preset
let preset = library.get("303 Acid")?;
let patch = preset.build(44100.0)?;
Example: Patch Manager
//! How-To: Serialize and Save Patches
//!
//! Demonstrates saving patches to JSON and loading them back.
//! Essential for preset management and patch storage.
//!
//! Run with: cargo run --example howto_serialization
use quiver::prelude::*;
fn main() {
let sample_rate = 44100.0;
println!("=== Patch Serialization Demo ===\n");
// Build a patch
let mut patch = Patch::new(sample_rate);
let vco = patch.add("vco", Vco::new(sample_rate));
let vcf = patch.add("vcf", Svf::new(sample_rate));
let vca = patch.add("vca", Vca::new());
let env = patch.add("env", Adsr::new(sample_rate));
let lfo = patch.add("lfo", Lfo::new(sample_rate));
let output = patch.add("output", StereoOutput::new());
// Audio path
patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
patch.connect(vcf.out("lp"), vca.in_("in")).unwrap();
patch.connect(vca.out("out"), output.in_("left")).unwrap();
patch.connect(vca.out("out"), output.in_("right")).unwrap();
// Modulation
patch.connect(env.out("env"), vcf.in_("cutoff")).unwrap();
patch.connect(env.out("env"), vca.in_("cv")).unwrap();
patch.connect(lfo.out("sin"), vcf.in_("fm")).unwrap();
patch.set_output(output.id());
patch.compile().unwrap();
println!(
"Original patch: {} modules, {} cables\n",
patch.node_count(),
patch.cable_count()
);
// Serialize to JSON
let mut def = patch.to_def("Warm Pad");
def.author = Some("Quiver Documentation".to_string());
def.description = Some("A warm pad with LFO filter modulation".to_string());
def.tags = vec!["pad".into(), "warm".into(), "modulated".into()];
let json = def.to_json().expect("Serialization failed");
println!("--- Serialized JSON ---");
println!("{}\n", json);
// Deserialize and rebuild
println!("--- Deserializing ---");
let loaded_def = PatchDef::from_json(&json).expect("Deserialization failed");
println!("Loaded patch: {}", loaded_def.name);
println!(" Author: {:?}", loaded_def.author);
println!(" Description: {:?}", loaded_def.description);
println!(" Tags: {:?}", loaded_def.tags);
println!(" Modules: {}", loaded_def.modules.len());
println!(" Cables: {}", loaded_def.cables.len());
// Rebuild the patch using the registry
let registry = ModuleRegistry::new();
let mut reloaded_patch =
Patch::from_def(&loaded_def, ®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);
}
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/wasm | Core WASM engine and AudioWorklet utilities |
@quiver/react | React hooks for UI integration |
@quiver/types | TypeScript type definitions |
Installation
npm install @quiver/wasm @quiver/react
Initializing the Engine
The WASM module must be initialized before use:
import { initWasm, createEngine } from '@quiver/wasm';
// Initialize once at app startup
await initWasm();
// Create an engine instance (44100 Hz sample rate)
const engine = await createEngine(44100);
Building a Patch
Add Modules
// Add modules by name and type
engine.add_module('vco', 'Vco');
engine.add_module('vca', 'Vca');
engine.add_module('output', 'StereoOutput');
Connect Modules
// Connect output port 0 of 'vco' to input port 0 of 'vca'
engine.connect('vco', 0, 'vca', 0);
// Connect VCA to stereo output (both channels)
engine.connect('vca', 0, 'output', 0); // Left
engine.connect('vca', 0, 'output', 1); // Right
Compile the Graph
After adding or connecting modules, compile the graph:
engine.compile();
Processing Audio
Sample-by-Sample
// Process one sample, returns [left, right]
const [left, right] = engine.tick();
Block Processing (Recommended)
More efficient for real-time audio:
// Process 128 samples at once
const samples = engine.process_block(128);
// Returns Float64Array: [L0, R0, L1, R1, ...]
AudioWorklet Setup
For real-time audio output in the browser:
import { createQuiverAudioNode } from '@quiver/wasm';
async function startAudio(engine) {
const audioContext = new AudioContext();
// Create worklet node connected to engine
const quiverNode = await createQuiverAudioNode(audioContext, engine);
// Connect to speakers
quiverNode.connect(audioContext.destination);
// Start (requires user gesture)
await audioContext.resume();
return { audioContext, quiverNode };
}
Architecture
Main Thread Audio Thread
┌─────────────┐ ┌─────────────────┐
│ React UI │ ──postMessage──▶│ AudioWorklet │
│ (params) │ ◀──────────────│ (process) │──▶ Speakers
└─────────────┘ └─────────────────┘
React Integration
useQuiverEngine
Initialize the engine in a component:
import { useQuiverEngine } from '@quiver/react';
function Synth() {
const { engine, loading, error } = useQuiverEngine(44100);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <PatchEditor engine={engine} />;
}
useQuiverParam
Bind a parameter to UI:
import { useQuiverParam } from '@quiver/react';
function FrequencyKnob({ engine, nodeId }) {
const [value, setValue, info] = useQuiverParam(engine, nodeId, 0);
return (
<Knob
value={value}
min={info.min}
max={info.max}
onChange={setValue}
/>
);
}
useQuiverLevel
Display level meters:
import { useQuiverLevel } from '@quiver/react';
function Meter({ engine, nodeId, portId }) {
const { rms_db, peak_db } = useQuiverLevel(engine, nodeId, portId);
return <LevelMeter rms={rms_db} peak={peak_db} />;
}
Next Steps
- Module Catalog - Browse and search available modules
- Observable Streaming - Real-time visualization data
- Serialization - Save and load patches
Module Catalog
The module catalog provides a searchable registry of all available modules with metadata for building dynamic UIs.
Browsing Modules
Get All Modules
const catalog = engine.catalog();
// Returns array of CatalogEntry:
// {
// type_id: "Vco",
// name: "VCO",
// category: "oscillator",
// description: "Voltage-controlled oscillator with multiple waveforms",
// keywords: ["oscillator", "vco", "saw", "square", "triangle"],
// inputs: [{ id: 0, name: "v_oct", kind: "VoltPerOctave" }, ...],
// outputs: [{ id: 0, name: "out", kind: "Audio" }],
// params: [{ id: "0", name: "frequency", ... }]
// }
Filter by Category
const oscillators = engine.by_category('oscillator');
const filters = engine.by_category('filter');
const utilities = engine.by_category('utility');
Categories
| Category | Description | Examples |
|---|---|---|
oscillator | Sound sources | Vco, Lfo, NoiseGenerator |
envelope | Time-based modulation | AdsrEnvelope, SlewLimiter |
filter | Frequency shaping | SvfFilter, DiodeLadderFilter |
amplifier | Level control | Vca, Mixer, Attenuverter |
effect | Audio effects | Saturator, Wavefolder, RingModulator |
utility | CV/signal utilities | Quantizer, SampleAndHold, Clock |
io | Input/output | ExternalInput, StereoOutput |
Searching Modules
Full-text search with relevance scoring:
const results = engine.search('filter');
// Returns matches sorted by relevance:
// [
// { entry: {...}, score: 100 }, // Exact type_id match
// { entry: {...}, score: 80 }, // Name contains "filter"
// { entry: {...}, score: 50 }, // Keyword match
// ]
Search Matching
| Match Type | Score | Example |
|---|---|---|
| Exact type_id | 100 | “Vco” matches Vco |
| Name contains | 80-90 | “osc” matches “VCO” |
| Description | 60 | “waveform” matches VCO description |
| Keyword | 40-50 | “analog” matches tagged modules |
| Category | 10 | “filter” matches filter category |
Module Metadata
Each catalog entry provides rich metadata for UI generation:
Port Information
const entry = catalog.find(m => m.type_id === 'SvfFilter');
// Input ports
entry.inputs.forEach(port => {
console.log(port.id, port.name, port.kind);
// 0, "input", "Audio"
// 1, "cutoff", "CvBipolar"
// 2, "resonance", "CvUnipolar"
});
// Output ports
entry.outputs.forEach(port => {
console.log(port.id, port.name, port.kind);
// 0, "lowpass", "Audio"
// 1, "highpass", "Audio"
// 2, "bandpass", "Audio"
});
Parameter Information
entry.params.forEach(param => {
console.log(param);
// {
// id: "0",
// name: "cutoff",
// min: 20.0,
// max: 20000.0,
// default: 1000.0,
// curve: "exponential",
// control_type: "knob",
// format: { type: "frequency" }
// }
});
Building a Module Browser UI
function ModuleBrowser({ engine, onSelect }) {
const [query, setQuery] = useState('');
const [category, setCategory] = useState(null);
const modules = useMemo(() => {
if (query) {
return engine.search(query).map(r => r.entry);
}
if (category) {
return engine.by_category(category);
}
return engine.catalog();
}, [query, category]);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search modules..."
/>
<select onChange={e => setCategory(e.target.value || null)}>
<option value="">All Categories</option>
<option value="oscillator">Oscillators</option>
<option value="filter">Filters</option>
<option value="envelope">Envelopes</option>
{/* ... */}
</select>
<ul>
{modules.map(m => (
<li key={m.type_id} onClick={() => onSelect(m.type_id)}>
<strong>{m.name}</strong>
<span>{m.category}</span>
<p>{m.description}</p>
</li>
))}
</ul>
</div>
);
}
Signal Type Colors
For cable visualization, use the standard signal colors:
const colors = engine.signal_colors();
// {
// audio: "#ff6b6b", // Red
// cv_bipolar: "#4ecdc4", // Cyan
// cv_unipolar: "#95e879",// Green
// volt_per_octave: "#ffd93d", // Yellow
// gate: "#c44dff", // Purple
// trigger: "#ff6bcb", // Magenta
// clock: "#ffa94d" // Orange
// }
Port Compatibility
Check if two ports can be connected:
const compat = engine.check_compatibility(
'vco', 0, // source: VCO output 0
'vcf', 0 // dest: VCF input 0
);
// Returns: "exact" | "allowed" | "warning" | "incompatible"
| Result | Meaning | UI Hint |
|---|---|---|
exact | Same signal type | Green cable |
allowed | Compatible types | Normal cable |
warning | May clip or mismatch | Yellow cable |
incompatible | Cannot connect | Prevent connection |
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/react package provides hooks for common patterns:
import {
useQuiverLevel,
useQuiverScope,
useQuiverGate,
useQuiverSpectrum
} from '@quiver/react';
function OutputMeter({ engine }) {
const { rms_db, peak_db } = useQuiverLevel(engine, 'output', 0);
return <Meter rms={rms_db} peak={peak_db} />;
}
function VcoScope({ engine }) {
const { samples } = useQuiverScope(engine, 'vco', 0, 512);
return <Oscilloscope samples={samples} />;
}
function LfoLed({ engine }) {
const { active } = useQuiverGate(engine, 'lfo', 1);
return <Led on={active} />;
}
Performance Tips
- 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);
SIMD Vectorization
SIMD (Single Instruction Multiple Data) processes 4-8 samples simultaneously:
flowchart LR
subgraph "Scalar"
A1[a₁] --> OP1[×]
B1[b₁] --> OP1
OP1 --> R1[c₁]
end
subgraph "SIMD (4-wide)"
A2["[a₁ a₂ a₃ a₄]"] --> OP2[×]
B2["[b₁ b₂ b₃ b₄]"] --> OP2
OP2 --> R2["[c₁ c₂ c₃ c₄]"]
end
Enabling SIMD
# Cargo.toml
[dependencies]
quiver = { version = "0.1", features = ["simd"] }
SIMD Operations
use quiver::simd::*;
let mut block = AudioBlock::new();
// SIMD-accelerated operations
block.add_scalar(offset); // Add constant
block.mul_scalar(gain); // Multiply by constant
block.add_block(&other); // Add another block
block.mul_block(&envelope); // Multiply by envelope
// These use SSE/AVX when available
Alignment
SIMD requires aligned memory:
// AudioBlock is automatically aligned
let block = AudioBlock::new(); // 16-byte aligned
// Manual alignment for custom types
#[repr(align(16))]
struct MyBuffer([f64; 64]);
Lazy Evaluation
Defer computation until needed:
use quiver::simd::{LazySignal, LazyBlock};
// Create lazy signal
let lazy = LazySignal::new(|| expensive_computation());
// Value computed only when needed
let value = lazy.evaluate();
// Lazy block operations
let lazy_block = LazyBlock::new()
.add_scalar(1.0)
.mul_scalar(0.5)
.add_block(&other);
// All operations fused when materialized
let result = lazy_block.materialize();
Fusion Benefits
// Without fusion: 3 loops
for s in block { s += 1.0; }
for s in block { s *= 0.5; }
for s in block { s += other[i]; }
// With fusion: 1 loop
for i in 0..len {
block[i] = (block[i] + 1.0) * 0.5 + other[i];
}
Ring Buffers
Efficient delay lines:
use quiver::simd::RingBuffer;
let mut delay = RingBuffer::new(44100); // 1 second
// Write sample, get delayed sample
let delayed = delay.tick(input);
// Access specific delay
let tapped = delay.read(11025); // 0.25 second delay
flowchart LR
IN[Input] --> WRITE[Write<br/>Head]
WRITE --> BUF[Circular<br/>Buffer]
BUF --> READ[Read<br/>Head]
READ --> OUT[Output]
WRITE -.->|wrap| WRITE
READ -.->|wrap| READ
ProcessContext
Bundle processing state:
let ctx = ProcessContext {
sample_rate: 44100.0,
block_size: 64,
transport_position: 0,
is_playing: true,
};
module.process_with_context(&mut block, &ctx);
Best Practices
1. Preallocate Everything
// Do this once at startup
let mut block = AudioBlock::new();
let mut delay = RingBuffer::new(max_delay);
// Not in the audio callback
let block = AudioBlock::new(); // ❌ Allocation!
2. Avoid Branching in Inner Loops
// Bad: branch per sample
for i in 0..len {
if condition {
block[i] = process_a(block[i]);
} else {
block[i] = process_b(block[i]);
}
}
// Good: branch once per block
if condition {
for i in 0..len { block[i] = process_a(block[i]); }
} else {
for i in 0..len { block[i] = process_b(block[i]); }
}
3. Use Block Operations
// Bad: call per sample
for i in 0..len {
block[i] = vco.tick();
}
// Good: block processing
vco.process(&[], &mut block);
4. Profile Regularly
use std::time::Instant;
let start = Instant::now();
process_block(&mut block);
let duration = start.elapsed();
if duration.as_secs_f64() * 1000.0 > 2.9 {
eprintln!("Warning: approaching deadline!");
}
Memory Layout
Cache-Friendly Access
// Good: sequential access
for i in 0..len {
output[i] = input[i] * gain;
}
// Bad: strided access
for i in (0..len).step_by(4) {
output[i] = input[i] * gain;
}
Structure of Arrays
For multiple parallel signals:
// Array of Structures (cache unfriendly)
struct Voice { phase: f64, freq: f64, amp: f64 }
let voices: [Voice; 8];
// Structure of Arrays (cache friendly)
struct Voices {
phases: [f64; 8],
freqs: [f64; 8],
amps: [f64; 8],
}
Real-Time Latency Constraints
Real-time audio processing requires strict timing guarantees. This guide explains latency budgets, how to calculate them, and strategies for meeting real-time constraints.
The Fundamental Constraint
Audio hardware delivers samples in fixed-size buffers at regular intervals. Your processing must complete before the next buffer arrives, or you’ll hear clicks, pops, or dropouts.
sequenceDiagram
participant HW as Audio Hardware
participant CPU as Your Code
HW->>CPU: Buffer N arrives
Note over CPU: Process buffer
CPU->>HW: Buffer N complete
HW->>CPU: Buffer N+1 arrives
Note over CPU: Must finish before<br/>next buffer!
Time Budget Calculation
The time budget is determined by:
time_budget = buffer_size / sample_rate
Common Configurations
| Sample 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 | ±10V | Pitch (0V = C4) |
fm | Bipolar CV | ±5V | Frequency modulation |
pw | Unipolar CV | 0-10V | Pulse width (5V = 50%) |
sync | Gate | 0/5V | Hard sync reset |
Outputs
| Port | Signal | Description |
|---|---|---|
sin | Audio | Sine wave |
tri | Audio | Triangle wave |
saw | Audio | Sawtooth wave |
sqr | Audio | Square/pulse wave |
Waveform Mathematics
Sine: $$y(t) = A \sin(2\pi f t)$$
Sawtooth (BLIT): $$y(t) = 2 \left( \frac{t}{T} - \lfloor \frac{t}{T} + 0.5 \rfloor \right)$$
Triangle: $$y(t) = 2 \left| 2 \left( \frac{t}{T} - \lfloor \frac{t}{T} + 0.5 \rfloor \right) \right| - 1$$
Square/Pulse: $$y(t) = \text{sign}(\sin(2\pi f t) - \cos(\pi \cdot \text{PW}))$$
Usage Example
// Basic VCO with external pitch
patch.connect(pitch_cv.out("out"), vco.in_("voct"))?;
// FM synthesis
patch.connect(modulator.out("sin"), vco.in_("fm"))?;
// PWM (pulse width modulation)
patch.connect(lfo.out("tri"), vco.in_("pw"))?;
LFO (Low-Frequency Oscillator)
Sub-audio oscillator for modulation.
let lfo = patch.add("lfo", Lfo::new(44100.0));
Inputs
| 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.
let noise = patch.add("noise", NoiseGenerator::new());
Outputs
| Port | Signal | Description |
|---|---|---|
white_left | Audio | White noise (left) |
white_right | Audio | White noise (right) |
pink_left | Audio | Pink noise (left) |
pink_right | Audio | Pink noise (right) |
Noise Spectra
White noise: Equal energy per frequency (flat spectrum)
$$S(f) = \text{constant}$$
Pink noise: Equal energy per octave (-3dB/octave)
$$S(f) \propto \frac{1}{f}$$
Pink noise is generated using the Voss-McCartney algorithm.
AnalogVco
VCO with analog imperfections for authentic vintage sound.
use quiver::analog::AnalogVco;
let vco = patch.add("vco", AnalogVco::new(44100.0));
Additional Features
- V/Oct tracking errors
- Component tolerance variation
- High-frequency rolloff
- Soft saturation
See Analog Modeling for details.
Common Patterns
Detuned Oscillators
let vco1 = patch.add("vco1", Vco::new(sr));
let vco2 = patch.add("vco2", Vco::new(sr));
// Slight detune for thickness
let detune = patch.add("detune", Offset::new(0.01)); // ~12 cents
patch.connect(pitch.out("out"), vco1.in_("voct"))?;
patch.connect(pitch.out("out"), vco2.in_("voct"))?;
patch.connect(detune.out("out"), vco2.in_("voct"))?; // Adds to pitch
Hard Sync
// Slave syncs to master
patch.connect(master.out("sqr"), slave.in_("sync"))?;
// Modulate slave pitch for classic sync sweep
patch.connect(lfo.out("sin"), slave.in_("voct"))?;
FM Synthesis
// Carrier:Modulator = 1:1 for harmonic FM
patch.connect(modulator.out("sin"), carrier.in_("fm"))?;
// Control FM depth with envelope
patch.connect(env.out("env"), fm_vca.in_("cv"))?;
patch.connect(fm_vca.out("out"), carrier.in_("fm"))?;
Filters
Filters shape the harmonic content of sound by attenuating certain frequencies while passing others.
SVF (State-Variable Filter)
A versatile 12dB/octave filter with multiple simultaneous outputs.
let vcf = patch.add("vcf", Svf::new(44100.0));
Inputs
| Port | Signal | Range | Description |
|---|---|---|---|
in | Audio | ±5V | Audio input |
cutoff | Unipolar CV | 0-10V | Cutoff frequency |
resonance | Unipolar CV | 0-10V | Resonance/Q (0-1) |
fm | Bipolar CV | ±5V | Frequency modulation |
tracking | V/Oct | ±10V | Keyboard tracking |
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 | Character |
|---|---|
| 0.0 | Flat response |
| 0.5 | Slight peak |
| 0.9 | Prominent peak |
| 0.95+ | Self-oscillation |
At high resonance, the filter produces a sine wave at the cutoff frequency.
DiodeLadderFilter
Classic 24dB/octave ladder filter with diode saturation modeling.
let ladder = patch.add("filter", DiodeLadderFilter::new(44100.0));
Inputs
| Port | Signal | Description |
|---|---|---|
in | Audio | Audio input |
cutoff | Unipolar CV | Cutoff frequency |
resonance | Unipolar CV | Resonance (0-1) |
drive | Unipolar CV | Saturation amount |
Output
| Port | Signal | Description |
|---|---|---|
out | Audio | Filtered output |
Characteristics
- 24dB/octave slope (4-pole)
- Diode saturation per stage
- Warm, slightly dirty character
- Resonance with bass loss (like original Moog)
The Ladder Topology
flowchart LR
IN[Input] --> S1[Stage 1<br/>-6dB/oct]
S1 --> S2[Stage 2<br/>-6dB/oct]
S2 --> S3[Stage 3<br/>-6dB/oct]
S3 --> S4[Stage 4<br/>-6dB/oct]
S4 --> OUT[Output<br/>-24dB/oct]
S4 -->|Resonance| IN
Filter Modulation Techniques
Envelope → Filter
Classic brightness sweep:
patch.connect(env.out("env"), vcf.in_("cutoff"))?;
// Fast decay = plucky, slow decay = pad
LFO → Filter
Rhythmic movement:
patch.connect(lfo.out("sin"), vcf.in_("fm"))?;
Keyboard Tracking
Higher notes = higher cutoff:
patch.connect(pitch.out("out"), vcf.in_("tracking"))?;
// 100% tracking: cutoff follows pitch
Audio-Rate FM
Metallic/vocal effects:
// Use oscillator as modulation source
patch.connect(vco2.out("sin"), vcf.in_("fm"))?;
Response Curves
dB
0 ├──────────────┐
│ ╲
-6 ├ ╲
│ ╲ LP
-12├ ╲
│ ╲
-24├ ╲
└────────────────────────
fc Frequency
Common Settings
| 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 | Trigger/sustain signal |
attack | Unipolar CV | 0-10V | Attack time (ms-s) |
decay | Unipolar CV | 0-10V | Decay time (ms-s) |
sustain | Unipolar CV | 0-10V | Sustain level (0-100%) |
release | Unipolar CV | 0-10V | Release time (ms-s) |
Output
| Port | Signal | Description |
|---|---|---|
env | Unipolar CV | Envelope output (0-5V) |
Envelope Stages
Level
5V ┤ ╱╲
│ ╱ ╲____
│ ╱ ╲
│ ╱ ╲
0V ┼╱────────────╲────
A D S R
Timing Curves
All stages use exponential curves:
Attack: $$v(t) = 5 \cdot (1 - e^{-t/\tau_a})$$
Decay/Release: $$v(t) = (v_{start} - v_{end}) \cdot e^{-t/\tau} + v_{end}$$
Typical Settings
| 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 |
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_left"), sh.in_("in"))?;
patch.connect(clock.out("div_8"), sh.in_("trigger"))?;
patch.connect(sh.out("out"), vcf.in_("cutoff"))?;
Slew Limiter
Limits rate of change—creates portamento and smoothing.
let slew = patch.add("slew", SlewLimiter::new(44100.0));
Inputs
| 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 continuous CV to scale degrees.
let quant = patch.add("quant", Quantizer::new());
Inputs
| Port | Signal | Description |
|---|---|---|
in | V/Oct | Unquantized pitch |
scale | CV | Scale selection |
Output
| Port | Signal | Description |
|---|---|---|
out | V/Oct | Quantized pitch |
Available Scales
| Scale | Notes |
|---|---|
| Chromatic | All 12 semitones |
| Major | 1 2 3 4 5 6 7 |
| Minor | 1 2 ♭3 4 5 ♭6 ♭7 |
| Pentatonic | 1 2 3 5 6 |
| Blues | 1 ♭3 4 ♭5 5 ♭7 |
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.
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 |
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
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 through wavefolding.
let folder = patch.add("folder", Wavefolder::new());
Inputs
| Port | Signal | Description |
|---|---|---|
in | Audio | Input signal |
folds | Unipolar CV | Number of folds |
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
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
for preset in library.list() {
println!("{}: {}", preset.name, preset.description);
}
// Get by category
let basses = library.by_category(PresetCategory::Bass);
// Search by tag
let acid = library.search_tags(&["acid"]);
// Build a preset
let patch = library.get("Moog Bass")?.build(44100.0)?;
Categories
| 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