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.
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.
speed
at which to move through each stage of this wavetable based on the sample rate and stage time.speed
to determine our current position
in the table. position
. 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
];
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:
Using a single wavetable: In my opinion, storing the envelope wavetable (super-table) in 4 parts is a better way to illustrate what is really happening here. That being said, a single wavetable is more efficient and easier to manage, especially for real-time or embedded applications. The only tradeoff is you need to track the index ranges for each stage, but this is straightforward.
Table Resolution: Higher resolution (more samples) gives smoother curves but uses more memory and may be slower. Choose a resolution that balances quality and performance for your use case.
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)];
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;
time
is the amount of time in seconds we want this stage to last. time
by 4
because each stage only takes up 1/4
of the entire super-table.sampleRate
to adjust for the number of samples per second.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
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:
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:
Release from Non-Sustain: If note-off occurs before reaching sustain, ensure the release phase starts from the current value, not always from the sustain level. The only thing that needs to change to make the new table is the sustain level. Calculating a new table where sustain = envVal
will give the correct release values. This can be done either be re-calculating the table or switching to the correct pre-calculated table. Be sure you cache your current sustain value / table so that it can be restored when the envelope resets. This is implemented in the demo but not implemented in the example code for simplicity.
Envelope Re-triggering: Decide how to handle rapid note retriggers (e.g., should the envelope restart, or continue from its current value?). The demo/example code restarts when a new note is triggered.
Anti-Aliasing: For very fast envelopes, consider anti-aliasing if the output is used for audio-rate modulation. This is not implemented in the example code.
Stage Boundaries: Take care when advancing between stages to avoid discontinuities or glitches, especially if using a single table.
Memory Usage: For embedded or real-time systems, consider the memory footprint of your wavetable(s).
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
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