Skip to content

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: 1 while the note is held, 0 after. The math and the state machine are identical.

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];

Why 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 y[n]=any[n] = a^n 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 τ\tau: the time for an exponential to fall to 1/e36.8%1/e \approx 36.8\% of its start.

a=e1/(τfs)τ=1fslnaa = e^{-1/(\tau f_s)} \qquad\Longleftrightarrow\qquad \tau = \frac{-1}{f_s\,\ln a}

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);
}

Math note — where a = e^{-1/(τ·sr)} comes from. We want the decay ana^n to equal 1/e1/e exactly when n=τfsn = \tau f_s samples (i.e. after τ\tau seconds). So aτfs=e1a^{\tau f_s} = e^{-1}; take logs: τfslna=1\tau f_s \ln a = -1, hence a=e1/(τfs)a = e^{-1/(\tau f_s)}. Now you can ask for “20 ms attack” and get the right a at any sample rate. (Each τ\tau 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.

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:

a=ratio1/(tfs)a = \text{ratio}^{\,1/(t f_s)}

fn ratio2pole(t: f32, ratio: f32, sr: f32) f32 {
return std.math.pow(f32, ratio, 1.0 / (t * sr));
}

Math note — the overshoot trick. Aim the attack at 1+eps instead of 1. The curve heads for 1+eps but we stop it the instant it hits 1 — and because it is exponential, that happens in finite, controllable time. At that crossing the remaining gap is eps out of a total jump of 1+eps, i.e. ratio = eps/(1+eps). Same idea for release: aim at -eps, total jump sus+eps, so ratio = eps/(sus+eps). Derivation of the pole mirrors the time constant: atfs=ratioa^{tf_s} = \text{ratio}, so a=ratio1/(tfs)a = \text{ratio}^{1/(tf_s)}.

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;
}
};

Zig note — enums and exhaustive switch. State is a plain enum; values are written .attack, .idle, etc. (Zig infers the type from context, so no State.attack needed). The switch must 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 bare if (no else) 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*out before the switch. If you test the state first, the transition lags by one sample and the clamp (e.g. out = 1 at 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]).

Zero-length stages. As t0t \to 0, 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*out adds a tiny number to a large one. For long times pole is extremely close to 1 (a 10 s attack at 48 kHz needs pole ≈ 0.99998), so (1-pole)*target is minuscule next to pole*out and gets swallowed by rounding — a long attack can stall and never reach decay. The simplest fix is to make out, pole, target f64 for the envelope’s internal math and cast to f32 only at the output. (Zig makes the precision explicit, so this is a one-word change per field.) Bigger eps also 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.

  1. 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.
  2. Make it plucky (short decay, low sustain) vs. a pad (long attack, high sustain). Same machine, different parameters.
  3. Set attack to 10 s with f32 fields and watch decay never arrive; switch the internal fields to f64 and confirm it now reaches decay at ~10 s.
  4. Build a tiny melody: an array of {freq, gate-duration} notes, each rendered with its own Phasor + shared Adsr. You now have a monophonic synth.

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.