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
In this patch the four ADSR stages are not knob settings — they are CV inputs, each fed by an Offset module. The envelope shapes only the VCA, so what you hear is the pure volume contour:
Drag the four stages yourself and watch the contour respond in Envelopes Shape Time.
//! Tutorial: Envelope Shaping
//!
//! Demonstrates the ADSR envelope generator and how it shapes sound over time.
//! This is fundamental to giving synthesized sounds their character.
//!
//! # Why ADSR has four stages
//!
//! A held note isn't a static event — it has a beginning, a middle, and an
//! end, and each needs different timing:
//! - **Attack**: the time to rise from silence to full level once the gate
//! opens. Fast (a few ms) reads as percussive/plucky; slow (100s of ms)
//! reads as a swell/pad fade-in.
//! - **Decay**: the time to fall from that initial peak down to the
//! **Sustain** level — not a duration but a *held level* (0-1), the
//! volume the note stays at for as long as the gate remains open. This is
//! what makes a plucked-string patch (high decay, low sustain — the pluck
//! dies down to near-silence and stays there) sound different from an
//! organ patch (sustain near 1.0 — it just holds).
//! - **Release**: the time to fall from wherever the level was when the
//! gate closed back to zero. Short release = clipped/staccato; long
//! release = notes bleed into each other.
//!
//! Quiver's `Adsr` maps each stage's CV input onto an exponential 1ms-10s
//! time range, so small CV changes near the low end make a big perceptual
//! difference (just like a real synth's time knobs).
//!
//! Run with: cargo run --example tutorial_envelope
use quiver::prelude::*;
use std::sync::Arc;
fn main() {
let sample_rate = 44100.0;
let mut patch = Patch::new(sample_rate);
// Gate control - simulates key press
let gate_cv = Arc::new(AtomicF64::new(0.0));
let gate = patch.add("gate", ExternalInput::gate(Arc::clone(&gate_cv)));
// Sound source
let vco = patch.add("vco", Vco::new(sample_rate));
// ADSR envelope generator
let env = patch.add("env", Adsr::new(sample_rate));
// Amplifier controlled by envelope
let vca = patch.add("vca", Vca::new());
// Output
let output = patch.add("output", StereoOutput::new());
// Time-constant CVs. `Adsr` maps each 0-1 CV onto an exponential
// 1ms-10s range via `time = 0.001 * 10000^cv`, so these values were
// picked by solving that formula for the target times noted below —
// slow enough that the millisecond checkpoints in the loops below land
// inside each stage instead of after it has already finished.
let attack_cv = patch.add("attack_cv", Offset::new(0.62)); // ~300ms attack
let decay_cv = patch.add("decay_cv", Offset::new(0.5)); // ~100ms decay
let sustain_cv = patch.add("sustain_cv", Offset::new(0.5)); // hold at 50% level
let release_cv = patch.add("release_cv", Offset::new(0.64)); // ~350ms release
// Connections
patch.connect(gate.out("out"), env.in_("gate")).unwrap();
patch
.connect(attack_cv.out("out"), env.in_("attack"))
.unwrap();
patch
.connect(decay_cv.out("out"), env.in_("decay"))
.unwrap();
patch
.connect(sustain_cv.out("out"), env.in_("sustain"))
.unwrap();
patch
.connect(release_cv.out("out"), env.in_("release"))
.unwrap();
patch.connect(vco.out("saw"), vca.in_("in")).unwrap();
patch.connect(env.out("env"), vca.in_("cv")).unwrap();
patch.connect(vca.out("out"), output.in_("left")).unwrap();
patch.set_output(output.id());
patch.compile().unwrap();
println!("=== ADSR Envelope Demo ===\n");
// Helper: run the patch for `n` samples and return the peak amplitude
// seen. A *peak* (not just the last sample) is what we want here — a
// single instantaneous sample would just be wherever the sawtooth
// carrier happened to be in its cycle, not a meaningful envelope
// reading.
fn run_samples(patch: &mut Patch, n: usize) -> f64 {
let mut peak = 0.0_f64;
for _ in 0..n {
let (left, _) = patch.tick();
peak = peak.max(left.abs());
}
peak
}
// Start with gate off
println!("Initial state (gate off):");
let level = run_samples(&mut patch, 100);
println!(" Envelope level: {:.3}V\n", level);
// Gate ON - trigger attack
println!("Gate ON - Attack phase begins");
gate_cv.set(5.0);
// Sample the attack: with a ~300ms attack time, these checkpoints show
// the level climbing rather than already sitting at full scale.
for ms in [10, 25, 50, 100, 200] {
let samples = (sample_rate * ms as f64 / 1000.0) as usize;
let level = run_samples(&mut patch, samples);
println!(" {}ms: level = {:.2}V", ms, level);
}
// Let it reach sustain
println!("\nDecay → Sustain:");
let level = run_samples(&mut patch, (sample_rate * 0.5) as usize);
println!(" Sustain level: {:.2}V\n", level);
// Gate OFF - trigger release
println!("Gate OFF - Release phase begins");
gate_cv.set(0.0);
// With a ~350ms release, the level should ease down across these
// checkpoints instead of hitting zero immediately.
for ms in [50, 100, 200, 500] {
let samples = (sample_rate * ms as f64 / 1000.0) as usize;
let level = run_samples(&mut patch, samples);
println!(" +{}ms: level = {:.3}V", ms, level);
}
println!("\nThe envelope has completed its cycle.");
println!("Attack→Decay→Sustain (while held) →Release (when released)");
}
Run it with cargo run --example tutorial_envelope.
Envelope as Modulation Source
The envelope doesn’t just control volume. Route it to:
flowchart TD
ADSR[ADSR Envelope]
ADSR -->|brightness| VCF[Filter Cutoff]
ADSR -->|volume| VCA[Amplifier]
ADSR -->|depth| FM[FM Amount]
ADSR -->|color| PWM[Pulse Width]
Filter Envelope
Routing envelope to filter creates the classic “brightness sweep”:
- Plucky bass: Fast attack, fast decay, low sustain
- Brass stab: Medium attack, fast decay, medium sustain
- String pad: Slow attack, slow decay, high sustain
Dual Envelope Routing
Different amounts to different destinations:
| 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