Cookbook

ADSR envelope implementation with a playable example

Four stages, and why two of them are exponential and one is not.

Anyone whose notes click on or never end.

A keyboard tells you when a note starts and stops. An envelope decides how its loudness moves between those events. Without an envelope, switching an oscillator on and off can create abrupt edges that sound like clicks.

This ADSR has four stages: attack rises to full level, decay falls toward the sustain level while the key is held, and release fades after the key is released. Sustain is a level; the other three controls are times.

Controls#

ParameterDefaultRangeMeaning
attack0.010 s0.001–2 sLinear rise time from zero to full level.
decay0.25 s0.01–4 sTime for the distance from sustain to fall by 60 dB.
sustain0.400–1Level approached while a key remains held.
release0.45 s0.01–4 sTime for the released level to fall by 60 dB.

The input streams follow the CLI note layout: pitch in Hz, vel, modWheel and gate. The mod wheel is declared to preserve that slot order; this patch does not use it. The browser keyboard finds pitch, velocity and gate by name. out is the mono voice.

The complete patch#

An envelopePlayground
processor AdsrVoice
{
    input  stream float pitch;
    input  stream float vel;
    input  stream float modWheel;
    input  stream float gate;
    param  float attack  = 0.010f [0.001f, 2.0f] smooth 5.0f;
    param  float decay   = 0.25f  [0.01f, 4.0f]  smooth 5.0f;
    param  float sustain = 0.40f  [0.0f, 1.0f]   smooth 10.0f;
    param  float release = 0.45f  [0.01f, 4.0f]  smooth 5.0f;
    output stream float out;

    float env      = 0.0f;
    float attacking = 0.0f;
    float prevGate = 0.0f;
    float phase    = 0.0f;
    float freq     = 220.0f;
    float amp      = 0.8f;

    void main()
    {
        loop
        {
            let sr = processor.frequency;
            let attackTime  = max(attack, 0.0005f);
            let decayTime   = max(decay, 0.005f);
            let releaseTime = max(release, 0.005f);
            let attackInc = 1.0f / (attackTime * sr);
            let decayCoef   = exp(-6.907755f / (decayTime * sr));
            let releaseCoef = exp(-6.907755f / (releaseTime * sr));
            if (gate > 0.5f)
            {
                freq = pitch;
                amp = vel;
            }
            let rising = gate * (1.0f - prevGate);
            prevGate = gate;
            attacking = rising > 0.5f ? 1.0f : attacking;
            if (gate > 0.5f)
            {
                if (attacking > 0.5f)
                {
                    env = env + attackInc;
                    if (env > 1.0f)
                    {
                        env = 1.0f;
                        attacking = 0.0f;
                    }
                }
                else
                {
                    env = sustain + (env - sustain) * decayCoef;
                }
            }
            else
            {
                env = env * releaseCoef;
                attacking = 0.0f;
            }
            let step = freq / sr;
            let p = phase + step;
            phase = p > 1.0f ? p - 1.0f : p;

            out <- sin(phase * 6.2831853f) * env * amp * 0.7f;
            advance();
        }
    }
}

Follow a note from press to release#

On a rising gate, attacking becomes 1. The envelope adds 1 / (attackTime * sampleRate) every frame, reaching full level in the requested attack time when it starts from zero. Retriggering during an existing tail starts from the current level, so the remaining attack is shorter.

Once it reaches 1, the envelope approaches sustain. The equation sustain + (env - sustain) * decayCoef shrinks the remaining distance each frame. On release, env * releaseCoef instead approaches zero. A 60 dB fall means one thousandth of the initial amplitude, not mathematical zero.

The oscillator keeps running while the envelope falls. The patch retains its last frequency and velocity after the gate drops, so the release is a fading tone. Testing pitch > 0 for note-off would be wrong for a host that holds pitch through release; that is why the gate is a separate input.

Try it#

To compare decay, set attack to 0.01 and sustain to 0.1, then play a fresh note with decay at 0.05 and another at 2.0. Decay changes the fall after the attack, not a note that has already settled at sustain. If sustain is 1, there is no fall to hear at all. Use a held computer key so your mouse is free to adjust the sliders.

Release is different: compare its settings by letting go of the note. Keep sustain above zero so there is some level left to fade. The Stop button cuts the entire runner immediately; it does not trigger release.

Hold a note with a two-second attack and a low sustain level. You can hear the rise, then the fall to sustain. Release it with release at two seconds and listen for the tail. Next, retrigger before the tail ends and notice that the attack begins from the remaining level.

If a note never releases: check that your host updates the gate input. Changing pitch alone is not a note-off message. This patch is monophonic; use node arrays to give several voices separate envelopes.

Next: a delay, which is the same idea applied to time instead of amplitude.