Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Envelope Shaping

An envelope generator shapes how a parameter changes over time. The classic ADSR (Attack, Decay, Sustain, Release) envelope is the heartbeat of synthesis.

graph LR
    subgraph "ADSR Envelope"
        A[Attack] --> D[Decay]
        D --> S[Sustain]
        S --> R[Release]
    end

Anatomy of ADSR

    │     ╱╲
    │    ╱  ╲_______
    │   ╱           ╲
    │  ╱             ╲
    │ ╱               ╲
────┴───────────────────────
    A   D    S     R
    ↑   ↑    ↑     ↑
   Gate On        Gate Off
StageDescriptionTypical Range
AttackTime to reach peak (0→5V)1ms - 10s
DecayTime to fall to sustain level1ms - 10s
SustainLevel held while gate is high0V - 5V
ReleaseTime to return to zero1ms - 10s

The Mathematics

Each stage is typically an exponential curve:

Attack (exponential rise): $$v(t) = V_{max} \cdot (1 - e^{-t/\tau_a})$$

Decay/Release (exponential fall): $$v(t) = V_{start} \cdot e^{-t/\tau_d}$$

Where $\tau$ is the time constant. Analog envelopes have this natural exponential shape—it’s how capacitors charge and discharge.

Building the Example

//! 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:

DestinationAmountEffect
VCA100%Full volume control
VCF50%Subtle brightness sweep
Pitch5%Pitch “blip” on attack

Musical Applications

Plucky Synth Bass

Attack:  5ms   (instant)
Decay:   200ms (quick fall)
Sustain: 30%   (some body)
Release: 100ms (clean cutoff)

Swelling Pad

Attack:  2s    (slow fade in)
Decay:   500ms (gentle settle)
Sustain: 80%   (full and rich)
Release: 3s    (long tail)

Percussive Hit

Attack:  1ms   (instant)
Decay:   50ms  (very fast)
Sustain: 0%    (no sustain)
Release: 50ms  (immediate)

Envelope Stages Visualization

sequenceDiagram
    participant G as Gate
    participant E as Envelope

    Note over G,E: Note On
    G->>E: Gate HIGH (+5V)
    E->>E: Attack phase (rising)
    E->>E: Decay phase (falling)
    E->>E: Sustain phase (holding)

    Note over G,E: Note Off
    G->>E: Gate LOW (0V)
    E->>E: Release phase (falling to 0)

Next: Filter Modulation