6 · adsr — envelopes & the state machine
What separates an oscillator from an instrument? An envelope — the slow shape of the amplitude over a note’s life. Pluck a string and the sound ramps up, then decays; it does not switch on at full volume. This chapter builds an ADSR envelope generator, and on the way fixes the sample-rate problem left over from mix using the time constant.
The original drives the envelope from a joystick gate (button down = note on) in real time. Offline, the gate is just an array:
1while the note is held,0after. The math and the state machine are identical.
1 · From smoother to envelope (ASR)
Section titled “1 · From smoother to envelope (ASR)”The one-pole smoother from mix already makes envelopes. Feed it a gate that goes 0 → 1 → 0 and the smoothed output ramps up (attack), holds (sustain), and ramps down (release) — an ASR envelope. The code is literally the smoother again:
const OnePole = struct { mem: f32 = 0.0, fn tick(self: *OnePole, gate: f32) f32 { self.mem = 0.001 * gate + 0.999 * self.mem; return self.mem; }};// out[i] = env.tick(gate[i]) * in[i];static floatgate_tick(float gate){ static float mem = 0; mem = 0.001 * gate + 0.999 * mem; return mem;}// out[i] = gate_tick(gate[i]) * in[i]; // envelope an oscillatorWhy instruments need this. An oscillator’s spectrum is static — that is why we could write a clean Fourier series for it. A real instrument’s amplitude (and timbre) evolves. Multiply a static oscillator by a time-varying envelope and it suddenly sounds alive; without one, two repeated notes of the same pitch blur into one continuous tone.
2 · The time constant (fixing sample-rate dependence)
Section titled “2 · The time constant (fixing sample-rate dependence)”The decay counts in samples, so the same a runs twice as fast at 96 kHz as at 48 kHz. To describe speed in seconds (sample-rate independent), use the time constant : the time for an exponential to fall to of its start.
fn tau2pole(tau: f32, sr: f32) f32 { return @exp(-1.0 / (tau * sr));}fn pole2tau(a: f32, sr: f32) f32 { return -1.0 / (@log(a) * sr);}float tau2pole(float tau) { return expf(-1/(tau*sr)); }// pole2tau(a) = -1/(log(a)*sr)Math note — where
a = e^{-1/(τ·sr)}comes from. We want the decay to equal exactly when samples (i.e. after seconds). So ; take logs: , hence . Now you can ask for “20 ms attack” and get the rightaat any sample rate. (Each seconds the level drops about 8.69 dB — it never truly reaches zero, which is why we say “rate,” not exact “time.”)
Now gate_tick can compute its pole from a time in seconds: a = tau2pole(0.02, sr) for a 20 ms attack/release.
3 · ADSR: separate attack and release
Section titled “3 · ADSR: separate attack and release”ASR uses one rate for both attack and release, but a plucked note wants a fast attack and a long release. So we add a decay stage and split the parameters into four:
- Attack — time to rise 0 → 1.
- Decay — time to fall 1 → sustain level.
- Sustain — the held level (not a time).
- Release — time to fall sustain → 0 after the gate releases.
3.1 Converting a time to a pole: ratio2pole
Section titled “3.1 Converting a time to a pole: ratio2pole”An exponential never actually reaches its target, so “attack time” needs a definition. The trick: aim slightly past the target (overshoot by eps) and switch stages when the real target is crossed. The pole that covers a jump in time t, leaving a fraction ratio of the jump remaining, is:
fn ratio2pole(t: f32, ratio: f32, sr: f32) f32 { return std.math.pow(f32, ratio, 1.0 / (t * sr));}float ratio2pole(float t, float ratio){ return powf(ratio, 1/(t*sr));}Math note — the overshoot trick. Aim the attack at
1+epsinstead of1. The curve heads for1+epsbut we stop it the instant it hits1— and because it is exponential, that happens in finite, controllable time. At that crossing the remaining gap isepsout of a total jump of1+eps, i.e.ratio = eps/(1+eps). Same idea for release: aim at-eps, total jumpsus+eps, soratio = eps/(sus+eps). Derivation of the pole mirrors the time constant: , so .
3.2 The state machine
Section titled “3.2 The state machine”An ADSR is naturally a finite state machine: IDLE → ATTACK → DECAY → SUSTAIN → RELEASE → IDLE. Each stage has its own target and pole; a gate edge forces a stage change. Original C (abbreviated):
The Zig version puts the whole machine in a struct with an enum state:
const State = enum { idle, attack, decay, sustain, release };
const Adsr = struct { sr: f32, atk: f32 = 0.01, // attack time (s) dec: f32 = 0.10, // decay time (s) sus: f32 = 0.5, // sustain level rel: f32 = 0.20, // release time (s)
state: State = .idle, pole: f32 = 0.0, target: f32 = 0.0, out: f32 = 0.0, gate_old: f32 = 0.0,
const eps: f32 = 0.001;
fn tick(self: *Adsr, gate: f32) f32 { if (gate > self.gate_old) { // 0 -> 1: begin attack self.state = .attack; self.target = 1.0 + eps; self.pole = ratio2pole(self.atk, eps / self.target, self.sr); } else if (gate < self.gate_old) { // 1 -> 0: begin release self.state = .release; self.target = -eps; self.pole = ratio2pole(self.rel, eps / (self.sus + eps), self.sr); } self.gate_old = gate;
// apply the exponential step BEFORE deciding the next state self.out = (1.0 - self.pole) * self.target + self.pole * self.out;
switch (self.state) { .idle => return 0.0, .attack => if (self.out >= 1.0) { self.out = 1.0; self.state = .decay; self.target = self.sus - eps; self.pole = ratio2pole(self.dec, eps / (1.0 - self.sus + eps), self.sr); }, .decay => if (self.out <= self.sus) { self.out = self.sus; self.state = .sustain; }, .sustain => self.out = self.sus, .release => if (self.out <= 0.0) { self.out = 0.0; self.state = .idle; }, } return self.out; }};enum State { IDLE, ATTACK, DECAY, SUSTAIN, RELEASE };#define eps 0.001f
static sample_t adsr_tick(sample_t gate) { static enum State state = IDLE; static float pole, target, out = 0, gate_old = 0;
if (gate > gate_old) { /* 0 -> 1: start attack */ state = ATTACK; target = 1+eps; pole = ratio2pole(atk, eps/target); } else if (gate < gate_old) { /* 1 -> 0: start release */ state = RELEASE; target = -eps; pole = ratio2pole(rel, eps/(sus+eps)); } gate_old = gate;
out = (1-pole)*target + pole*out; /* apply the exponential step FIRST */
switch (state) { case IDLE: return 0; case ATTACK: if (out >= 1) { out = 1; state = DECAY; target = sus-eps; pole = ratio2pole(decay, eps/(1-sus+eps)); } break; case DECAY: if (out <= sus){ out = sus; state = SUSTAIN; } break; case SUSTAIN: out = sus; break; case RELEASE: if (out <= 0) { out = 0; state = IDLE; } break; } return out;}Zig note — enums and exhaustive
switch.Stateis a plain enum; values are written.attack,.idle, etc. (Zig infers the type from context, so noState.attackneeded). Theswitchmust cover every variant — leave one out and it will not compile, which is exactly the safety you want in a state machine. Each prong is one expression: a bareif(noelse) for stages that only sometimes transition, or a direct assignment for.sustain. Bundling the state and its parameters (atk,sus, …) in one struct means you can run several independent envelopes at once — one per voice — with no globals.
Math note — order matters. Apply
out = (1-pole)*target + pole*outbefore theswitch. If you test the state first, the transition lags by one sample and the clamp (e.g.out = 1at the top of attack) can be skipped, letting the value overshoot. Step, then decide.
To use it offline: build a gate array (1 while held, 0 after), then sample = osc * adsr.tick(gate[i]).
4 · Improvements
Section titled “4 · Improvements”Zero-length stages. As , ratio2pole divides by zero. But the limit is pole → 1, and with pole = 1 the step becomes out = target — an instant jump. So special-case t == 0 to set pole = 1 (and reject negatives).
Sustain must be > 0. If sus == 0, the release ratio is eps/eps = 1, so the tail never dies. Clamp sus slightly above zero, or redefine release as “from the current level to 0.”
Math/Zig note — numerical precision (use f64 internally). The step
out = (1-pole)*target + pole*outadds a tiny number to a large one. For long timespoleis extremely close to 1 (a 10 s attack at 48 kHz needspole ≈ 0.99998), so(1-pole)*targetis minuscule next topole*outand gets swallowed by rounding — a long attack can stall and never reach decay. The simplest fix is to makeout,pole,targetf64for the envelope’s internal math and cast tof32only at the output. (Zig makes the precision explicit, so this is a one-word change per field.) Biggerepsalso helps by straightening the curve.
Attack curve. The flat top of the attack can feel sluggish. Use a larger eps just for attack (e.g. 0.29) so the curve is cut off earlier and feels snappier.
Exercises
Section titled “Exercises”- Render one note: a saw (chapter 3) times an ADSR with a 5 ms attack, 120 ms decay, 0.4 sustain, 300 ms release. Make a gate that is 1 for 0.5 s then 0.
- Make it plucky (short decay, low sustain) vs. a pad (long attack, high sustain). Same machine, different parameters.
- Set attack to 10 s with
f32fields and watch decay never arrive; switch the internal fields tof64and confirm it now reaches decay at ~10 s. - Build a tiny melody: an array of
{freq, gate-duration}notes, each rendered with its ownPhasor+ sharedAdsr. You now have a monophonic synth.
Further reading
Section titled “Further reading”Original chapter with audio demos and the floating-point plots: mu.krj.st/adsr. Faust’s basics.lib has tau2pole/pole2tau; Will Pirkle, Designing Software Synthesizer Plug-Ins in C++, ch. 6, for multi-segment envelopes; the Herbie tool for analyzing floating-point error in expressions like the ADSR step.
Next: 7 · delay — echoes, feedback, and the circular buffer.