Envelope Shaping
An envelope generator shapes how a parameter changes over time. The classic ADSR (Attack, Decay, Sustain, Release) envelope is the heartbeat of synthesis.
graph LR
subgraph "ADSR Envelope"
A[Attack] --> D[Decay]
D --> S[Sustain]
S --> R[Release]
end
Anatomy of ADSR
│ ╱╲
│ ╱ ╲_______
│ ╱ ╲
│ ╱ ╲
│ ╱ ╲
────┴───────────────────────
A D S R
↑ ↑ ↑ ↑
Gate On Gate Off
| Stage | Description | Typical Range |
|---|---|---|
| Attack | Time to reach peak (0→5V) | 1ms - 10s |
| Decay | Time to fall to sustain level | 1ms - 10s |
| Sustain | Level held while gate is high | 0V - 5V |
| Release | Time to return to zero | 1ms - 10s |
The Mathematics
Each stage is typically an exponential curve:
Attack (exponential rise): $$v(t) = V_{max} \cdot (1 - e^{-t/\tau_a})$$
Decay/Release (exponential fall): $$v(t) = V_{start} \cdot e^{-t/\tau_d}$$
Where $\tau$ is the time constant. Analog envelopes have this natural exponential shape—it’s how capacitors charge and discharge.
Building the Example
//! Tutorial: Envelope Shaping
//!
//! Demonstrates the ADSR envelope generator and how it shapes sound over time.
//! This is fundamental to giving synthesized sounds their character.
//!
//! # Why ADSR has four stages
//!
//! A held note isn't a static event — it has a beginning, a middle, and an
//! end, and each needs different timing:
//! - **Attack**: the time to rise from silence to full level once the gate
//! opens. Fast (a few ms) reads as percussive/plucky; slow (100s of ms)
//! reads as a swell/pad fade-in.
//! - **Decay**: the time to fall from that initial peak down to the
//! **Sustain** level — not a duration but a *held level* (0-1), the
//! volume the note stays at for as long as the gate remains open. This is
//! what makes a plucked-string patch (high decay, low sustain — the pluck
//! dies down to near-silence and stays there) sound different from an
//! organ patch (sustain near 1.0 — it just holds).
//! - **Release**: the time to fall from wherever the level was when the
//! gate closed back to zero. Short release = clipped/staccato; long
//! release = notes bleed into each other.
//!
//! Quiver's `Adsr` maps each stage's CV input onto an exponential 1ms-10s
//! time range, so small CV changes near the low end make a big perceptual
//! difference (just like a real synth's time knobs).
//!
//! Run with: cargo run --example tutorial_envelope
use quiver::prelude::*;
use std::sync::Arc;
fn main() {
let sample_rate = 44100.0;
let mut patch = Patch::new(sample_rate);
// Gate control - simulates key press
let gate_cv = Arc::new(AtomicF64::new(0.0));
let gate = patch.add("gate", ExternalInput::gate(Arc::clone(&gate_cv)));
// Sound source
let vco = patch.add("vco", Vco::new(sample_rate));
// ADSR envelope generator
let env = patch.add("env", Adsr::new(sample_rate));
// Amplifier controlled by envelope
let vca = patch.add("vca", Vca::new());
// Output
let output = patch.add("output", StereoOutput::new());
// Time-constant CVs. `Adsr` maps each 0-1 CV onto an exponential
// 1ms-10s range via `time = 0.001 * 10000^cv`, so these values were
// picked by solving that formula for the target times noted below —
// slow enough that the millisecond checkpoints in the loops below land
// inside each stage instead of after it has already finished.
let attack_cv = patch.add("attack_cv", Offset::new(0.62)); // ~300ms attack
let decay_cv = patch.add("decay_cv", Offset::new(0.5)); // ~100ms decay
let sustain_cv = patch.add("sustain_cv", Offset::new(0.5)); // hold at 50% level
let release_cv = patch.add("release_cv", Offset::new(0.64)); // ~350ms release
// Connections
patch.connect(gate.out("out"), env.in_("gate")).unwrap();
patch
.connect(attack_cv.out("out"), env.in_("attack"))
.unwrap();
patch
.connect(decay_cv.out("out"), env.in_("decay"))
.unwrap();
patch
.connect(sustain_cv.out("out"), env.in_("sustain"))
.unwrap();
patch
.connect(release_cv.out("out"), env.in_("release"))
.unwrap();
patch.connect(vco.out("saw"), vca.in_("in")).unwrap();
patch.connect(env.out("env"), vca.in_("cv")).unwrap();
patch.connect(vca.out("out"), output.in_("left")).unwrap();
patch.set_output(output.id());
patch.compile().unwrap();
println!("=== ADSR Envelope Demo ===\n");
// Helper: run the patch for `n` samples and return the peak amplitude
// seen. A *peak* (not just the last sample) is what we want here — a
// single instantaneous sample would just be wherever the sawtooth
// carrier happened to be in its cycle, not a meaningful envelope
// reading.
fn run_samples(patch: &mut Patch, n: usize) -> f64 {
let mut peak = 0.0_f64;
for _ in 0..n {
let (left, _) = patch.tick();
peak = peak.max(left.abs());
}
peak
}
// Start with gate off
println!("Initial state (gate off):");
let level = run_samples(&mut patch, 100);
println!(" Envelope level: {:.3}V\n", level);
// Gate ON - trigger attack
println!("Gate ON - Attack phase begins");
gate_cv.set(5.0);
// Sample the attack: with a ~300ms attack time, these checkpoints show
// the level climbing rather than already sitting at full scale.
for ms in [10, 25, 50, 100, 200] {
let samples = (sample_rate * ms as f64 / 1000.0) as usize;
let level = run_samples(&mut patch, samples);
println!(" {}ms: level = {:.2}V", ms, level);
}
// Let it reach sustain
println!("\nDecay → Sustain:");
let level = run_samples(&mut patch, (sample_rate * 0.5) as usize);
println!(" Sustain level: {:.2}V\n", level);
// Gate OFF - trigger release
println!("Gate OFF - Release phase begins");
gate_cv.set(0.0);
// With a ~350ms release, the level should ease down across these
// checkpoints instead of hitting zero immediately.
for ms in [50, 100, 200, 500] {
let samples = (sample_rate * ms as f64 / 1000.0) as usize;
let level = run_samples(&mut patch, samples);
println!(" +{}ms: level = {:.3}V", ms, level);
}
println!("\nThe envelope has completed its cycle.");
println!("Attack→Decay→Sustain (while held) →Release (when released)");
}
Envelope as Modulation Source
The envelope doesn’t just control volume. Route it to:
flowchart TD
ADSR[ADSR Envelope]
ADSR -->|brightness| VCF[Filter Cutoff]
ADSR -->|volume| VCA[Amplifier]
ADSR -->|depth| FM[FM Amount]
ADSR -->|color| PWM[Pulse Width]
Filter Envelope
Routing envelope to filter creates the classic “brightness sweep”:
- Plucky bass: Fast attack, fast decay, low sustain
- Brass stab: Medium attack, fast decay, medium sustain
- String pad: Slow attack, slow decay, high sustain
Dual Envelope Routing
Different amounts to different destinations:
| Destination | Amount | Effect |
|---|---|---|
| VCA | 100% | Full volume control |
| VCF | 50% | Subtle brightness sweep |
| Pitch | 5% | Pitch “blip” on attack |
Musical Applications
Plucky Synth Bass
Attack: 5ms (instant)
Decay: 200ms (quick fall)
Sustain: 30% (some body)
Release: 100ms (clean cutoff)
Swelling Pad
Attack: 2s (slow fade in)
Decay: 500ms (gentle settle)
Sustain: 80% (full and rich)
Release: 3s (long tail)
Percussive Hit
Attack: 1ms (instant)
Decay: 50ms (very fast)
Sustain: 0% (no sustain)
Release: 50ms (immediate)
Envelope Stages Visualization
sequenceDiagram
participant G as Gate
participant E as Envelope
Note over G,E: Note On
G->>E: Gate HIGH (+5V)
E->>E: Attack phase (rising)
E->>E: Decay phase (falling)
E->>E: Sustain phase (holding)
Note over G,E: Note Off
G->>E: Gate LOW (0V)
E->>E: Release phase (falling to 0)
Next: Filter Modulation