Mathieu Dombrock

"Baseless claims & crimes against computing."

ADSR Envelope

This post discusses my thoughts on implementing an ADSR envelope for real-time DSP applications. There are many, many ways to go about this. I'm not claiming this is the best or perfect soution but after careful consideration, I belive this approach is the best I have found so far.

Interactive Demo







General Idea

The idea here is to use a single, uniform, static wavetable to represent an ADSR envelope. The speed at which we move through the table is determined by the time we want each stage to last.

Simple Version

Less Simple Version

Why this approach makes sense

Notes on Example Code

The ADSR Envelope Wavetable

First we generate an ADSR Envelope wavetable. This is really a set of 4 wavetables, one for each stage of the envelope. I'll refer to the entire set as the super-table.

So the tables are:

Each individual table has an X length of 1/4. This means the Envelope super-table always has an X length of 1.

Since each stage always has a length of 1/4, we only need to regenerate the table if the sustain level changes. We do not need to regenerate the table if the attack, decay, or release times change. The attack, decay, and release shapes are independent of their durations because the speed of traversal is what changes.

The structure of the super-table is something like:

superTable = [
    attackTable, 
    decayTable, 
    sustainTable, 
    releaseTable
];

Generating the Tables

function generateADSRWavetables(sustainVal) {
  // Each wavetable is an array of normalized [0,1] samples
  // `N = 41;` sets the number of samples (points) used to generate each ADSR wavetable segment (attack, decay, sustain, release). 
  // This means each segment is represented by 42 points (from 0 to 41 inclusive). 
  // A higher N gives smoother curves but uses more memory and computation; a lower N makes the curves more jagged.
  const N = 41;
  const sustainYNorm = 1 - sustainVal;
  // Helper for exponential curve
  // `k` controls 'steepness'
  function expInterp(x, y0, y1, k) {
    return y0 + (y1 - y0) * (1 - Math.exp(-k * x)) / (1 - Math.exp(-k));
  }
  // Attack
  const attack = [];
  for (let i = 0; i <= N; ++i) {
    const t = i / N;
    attack.push(expInterp(t, 1, 0, 4)); // y: 1 (bottom) to 0 (top)
  }
  // Decay
  const decay = [];
  for (let i = 0; i <= N; ++i) {
    const t = i / N;
    decay.push(expInterp(t, 0, sustainYNorm, 3));
  }
  // Sustain (flat)
  const sustain = [];
  for (let i = 0; i <= N; ++i) {
    sustain.push(sustainYNorm);
  }
  // Release
  const release = [];
  for (let i = 0; i <= N; ++i) {
    const t = i / N;
    release.push(expInterp(t, sustainYNorm, 1, 2.5));
  }
  return { attack, decay, sustain, release };
}

Notes:

Calculating the Stage

We can represent the current position in the super-table with a position value between 0..1.

The relationship between the position in the super-table and the individual tables is:

| Stage    | Start  | End   |
|----------|--------|-------|
| Attack   | 0.00   | 0.25  |
| Decay    | 0.25   | 0.50  |
| Sustain  | 0.50   | 0.75  |
| Release  | 0.75   | 1.00  |

These relationships between the position and super-table stage never change. Instead of stretching the table to fit the time we want, we just vary the rate at which we change our position.

Since each stage of the super-table has a known, static X length of 0.25 we can get the current stage very easily.

For example, if we want to return the current stage index we might do something like:

function pos2Stage(position) {
    if (position < (1/4)) {
        return 0; // Attack
    }
    else if (position < (2/4)) {
        return 1; // Decay
    }
    else if (position < (3/4)) {
        return 2; // Sustain
    }
    else {
        return 3; // Release
    }
}
superTable[pos2Stage(position)];

This demonstrates the concept clearly but is not the most efficient or elegant way to get the super-table index.

A better way to get the current super-table index is simply:

superTable[Math.min(Math.floor(position * superTable.length), superTable.length - 1)];

Calculating the Speed

The speed value is the rate of change of the position in the super-table. The speed value is determined by the time we want each stage to last.

This value is calculated with the following formula:

speed = (1 / (time * 4)) / sampleRate;

For simplicity assume the sample rate is 1 and say we want a 1 second attack time. The speed would be:

speed = (1 / (1 * 4)) / 1;
// or
speed = (1 / 4);
// Either way:
// speed = 0.25

In this simplified example we can think of the entire table as having a period of 1 second. The attack stage takes up 1/4 of that period, so the speed is 0.25.

For a more realistic example, suppose we have defined our attack time as 10 seconds.

We can calculate the rate of change (speed) for the attack stage as:

speed = (1 / (10 * 4)) / sampleRate;

Every sample we increment our position by this speed amount. This will take 10 seconds to move through the attack stage of the table.

If we once again simplify this by assuming a sample rate of 1 we get:

speed = (1/ (10 * 4)) / 1;
// speed = 0.025

Dealing with Sustain

Each stage of our envelope aside from sustain deals with transitioning from value A to value B. Each other stage also has a known length in seconds.

Sustain is the exception here. It doesn't really represent a time based value at all.

Instead, sustain can go on for any (an unknown) length of time.

During sustain, position does not advance until noteOff, at which point position jumps to the start of release.

For example:

speed = ...;// Calculate the speed
// Do not increase position when sustained
if (isSustain) {
  speed = 0;
}

This means that if we are in the sustain stage, we are always at position == 0.5.

Because of this, we need to remember that when we get a noteOff event we must move our position to the start of the release stage:

position = 0.75;

Alternatively, this could be handled by setting the position value to 0.75 as soon as we enter the release stage:

speed = ...;// Calculate the speed
// Do not increase position when sustained
if (isSustain) {
  speed = 0;
  position = 0.75;
}

It doesn't functionally matter if the position is set when we enter the sustain stage or when we get a noteOff event. In my opinion it makes more sense to set the position value when we get the noteOff event because we also should set the position value to 0.0 when we get the noteOn event (reset).

We use this speed value to iterate through our envelope. We might implement a full function to do this as:

function iterateEnv(adsr, position, isNoteOn) {
  const section = Math.min(Math.floor(position * 4), adsr.length - 1);
  const isPreSustain = isNoteOn && position < 0.5; // Note is on and position is less than 0.5
  const isPostSustain = !isNoteOn; // Note is off
  const isSustain = !(isPreSustain || isPostSustain); // Are we sustaining the note?
  let speed = (1 / (adsr[section] * 4)) / sampleRate;
  // Do not increase position when sustained
  if (isSustain) {
    speed = 0;
  }
  position += speed;
  // Clamp position to max 1.0
  position = Math.min(position, 1.0);
  return position;
}

Note:

Calculating the Current Value

Given a wavetables super-table and our current position in the super-table we can calculate our current envelope output value as follows:

function getADSRValueAt(wavetables, position) {
  // position: 0..1
  const stages = ['attack', 'decay', 'sustain', 'release'];
  const sectionLength = 1 / stages.length; // 1/4
  // Current stage index
  const stageIndex = Math.min(Math.floor(position / sectionLength), stages.length - 1);
  // Normalized position within our current stage
  const localT = (position - stageIndex * sectionLength) / sectionLength;
  // Target table
  const table = wavetables[stages[stageIndex]];
  // Interpolate between nearest samples
  const N = table.length - 1;
  const idx = localT * N;
  const i0 = Math.floor(idx);
  const i1 = Math.min(i0 + 1, N);
  const frac = idx - i0;
  return 1 - (table[i0] * (1 - frac) + table[i1] * frac);
}

Note:

Final Thoughts

Implementation Examples

Simple JavaScript Implementation

This JavaScript code represents the general logic involved in this approach. It can be run in a JS console.

// For simplicity we will use global variables here
// However, functions will take these as parameters when possible
const sampleRate = 4;// Hz
const adsr = [1.0, 2.0, 0.5, 3.0]; // Time in seconds
// The `wavetables` value should update only when sustain changes
const wavetables = generateADSRWavetables(adsr[2]); // Send the sustain value
let isNoteOn = false;// `isNoteOn` Set to either true or false depending on env state
let position = 0;// `position` Set to our current position in the super-table
// Called when a note is triggered on
function noteOn() {
  isNoteOn = true;
  position = 0; // Reset the position in the super-table
}
// Called when a note is triggered off
function noteOff() {
  isNoteOn = false;
  position = 0.75; // Start of release
}
// Generate the ADSR wavetables
function generateADSRWavetables(sustainVal) {
  // Each wavetable is an array of normalized [0,1] samples
  // `N = 41;` sets the number of samples (points) used to generate each ADSR wavetable segment (attack, decay, sustain, release). 
  // This means each segment is represented by 42 points (from 0 to 41 inclusive). 
  // A higher N gives smoother curves but uses more memory and computation; a lower N makes the curves more jagged.
  const N = 41;
  const sustainYNorm = 1 - sustainVal;
  // Helper for exponential curve
  // `k` controls 'steepness'
  function expInterp(x, y0, y1, k) {
    return y0 + (y1 - y0) * (1 - Math.exp(-k * x)) / (1 - Math.exp(-k));
  }
  // Attack
  const attack = [];
  for (let i = 0; i <= N; ++i) {
    const t = i / N;
    attack.push(expInterp(t, 1, 0, 4)); // y: 1 (bottom) to 0 (top)
  }
  // Decay
  const decay = [];
  for (let i = 0; i <= N; ++i) {
    const t = i / N;
    decay.push(expInterp(t, 0, sustainYNorm, 3));
  }
  // Sustain (flat)
  const sustain = [];
  for (let i = 0; i <= N; ++i) {
    sustain.push(sustainYNorm);
  }
  // Release
  const release = [];
  for (let i = 0; i <= N; ++i) {
    const t = i / N;
    release.push(expInterp(t, sustainYNorm, 1, 2.5));
  }
  return { attack, decay, sustain, release };
}
// Get the ADSR env value at the position
function getADSRValueAt(wavetables, position) {
  // position: 0..1
  const stages = ['attack', 'decay', 'sustain', 'release'];
  const sectionLength = 1 / stages.length; // 1/4
  // Current stage index
  const stageIndex = Math.min(Math.floor(position / sectionLength), stages.length - 1);
  // Normalized position within our current stage
  const localT = (position - stageIndex * sectionLength) / sectionLength;
  // Target table
  const table = wavetables[stages[stageIndex]];
  // Interpolate between nearest samples
  const N = table.length - 1;
  const idx = localT * N;
  const i0 = Math.floor(idx);
  const i1 = Math.min(i0 + 1, N);
  const frac = idx - i0;
  return 1 - (table[i0] * (1 - frac) + table[i1] * frac);
}
// Iterate through the envelope
function iterateEnv(adsr, position, isNoteOn) {
  const section = Math.min(Math.floor(position * 4), adsr.length - 1);
  const isPreSustain = isNoteOn && position < 0.5; // Note is on and position is less than 0.5
  const isPostSustain = !isNoteOn; // Note is off
  const isSustain = !(isPreSustain || isPostSustain); // Are we sustaining the note?
  let speed = (1 / (adsr[section] * 4)) / sampleRate;
  // Do not increase position when sustained
  if (isSustain) {
    speed = 0;
  }
  position += speed;
  // Clamp position to max 1.0
  position = Math.min(position, 1.0);
  return position;
}
// Trigger the initial note on event
noteOn();
// Call the `getEnvValue` function at `sampleRate` Hz. 
setInterval(() => {
  // Trigger a `noteOff` event when we hit sustain
  if (position >= 0.5 && isNoteOn) {
    console.log('\nSustain reached');
    noteOff();
  }
  position = iterateEnv(adsr, position, isNoteOn);
  const val = getADSRValueAt(wavetables, position);
  console.log(`\nPosition: ${position.toFixed(3)}`);
  console.log(`Env Value: ${val.toFixed(3)}`); // Log the current env value
}, 1000 / sampleRate); // 1000 = 1 second

Simple C Implementation

Below is a re-implementation of the JS code in the C programming language.

#include <stdio.h>
#include <math.h>
#include <stdbool.h>
#include <unistd.h> // For usleep

#define N 41
#define STAGES 4
#define SAMPLE_RATE 4

typedef struct {
    float attack[N+1];
    float decay[N+1];
    float sustain[N+1];
    float release[N+1];
} Wavetables;

float expInterp(float x, float y0, float y1, float k) {
    return y0 + (y1 - y0) * (1 - expf(-k * x)) / (1 - expf(-k));
}

void generateADSRWavetables(Wavetables *wt, float sustainVal) {
    float sustainYNorm = 1.0f - sustainVal;
    for (int i = 0; i <= N; ++i) {
        float t = (float)i / N;
        wt->attack[i]  = expInterp(t, 1.0f, 0.0f, 4.0f);
        wt->decay[i]   = expInterp(t, 0.0f, sustainYNorm, 3.0f);
        wt->sustain[i] = sustainYNorm;
        wt->release[i] = expInterp(t, sustainYNorm, 1.0f, 2.5f);
    }
}

float getADSRValueAt(Wavetables *wt, float position) {
    // Note: It would be better/faster to not use strings
    // They are used here to keep things as close to the JS example as possible
    const char *stages[] = {"attack", "decay", "sustain", "release"};
    float sectionLength = 1.0f / STAGES;
    int stageIndex = (int)fminf(floorf(position / sectionLength), STAGES - 1);
    float localT = (position - stageIndex * sectionLength) / sectionLength;
    float *table;
    switch (stageIndex) {
        case 0: table = wt->attack; break;
        case 1: table = wt->decay; break;
        case 2: table = wt->sustain; break;
        case 3: table = wt->release; break;
        default: table = wt->release; break;
    }
    int len = N;
    float idx = localT * len;
    int i0 = (int)floorf(idx);
    int i1 = i0 < len ? i0 + 1 : len;
    float frac = idx - i0;
    float val = table[i0] * (1 - frac) + table[i1] * frac;
    return 1.0f - val;
}

float iterateEnv(float adsr[STAGES], float position, bool isNoteOn) {
    int section = (int)fminf(floorf(position * STAGES), STAGES - 1);
    bool isPreSustain = isNoteOn && position < 0.5f;
    bool isPostSustain = !isNoteOn;
    bool isSustain = !(isPreSustain || isPostSustain);
    float speed = (1.0f / (adsr[section] * STAGES)) / SAMPLE_RATE;
    if (isSustain) speed = 0.0f;
    position += speed;
    if (position > 1.0f) position = 1.0f;
    return position;
}

int main() {
    float adsr[STAGES] = {1.0f, 2.0f, 0.5f, 3.0f};
    Wavetables wt;
    generateADSRWavetables(&wt, adsr[2]);
    bool isNoteOn = true;
    float position = 0.0f;

    printf("Starting ADSR envelope demo (C version):\n");
    while (true) {
        if (position >= 0.5f && isNoteOn) {
            printf("\nSustain reached\n");
            isNoteOn = false;
            position = 0.75f;
        }
        position = iterateEnv(adsr, position, isNoteOn);
        float val = getADSRValueAt(&wt, position);
        printf("Position: %.3f, Env Value: %.3f\n", position, val);
        usleep(1000000 / SAMPLE_RATE); // Sleep for 1/sample_rate seconds
    }
    return 0;
}

Compile and run:

gcc adsr.c -lm
./a.out