Offline
Zero-phase
filtfiltAn envelope detector traces the loudness contour of a waveform — the slow outline riding over the fast carrier inside it. Every graph on this page is drawn by the method's real algorithm, and the sliders at the top drive all of them at once.
The whole method, live
Score card
- Causality
- zero-lag
- Signal model
- single-carrier
- Reads
- mean-rect ≈ 0.637×
- Latency
- none (zero-lag)
- Cost
- moderate
- Domain
- time
Tracking error vs the true envelope, by challenge axis — longer bar is a tighter fit. Computed live across the oracle generators.
How it works
Smooth and perfectly time-aligned — impossible in real time. Rectify, then run the same low-pass forward and then backward: the two passes have opposite phase, so all delay cancels and the envelope lands exactly on the signal with zero lag.
Because the filter is applied twice, the effective smoothing is steeper than a single pass, and each added pass sharpens it further. The price is causality — it must see the future, so it only works on recorded material.
Key terms
- Zero-phase filtering
- Filtering that introduces no time shift: the envelope lands exactly on the signal, with no lag between a swell and the curve that tracks it. That alignment is the whole point of this method.
- Forward–backward pass
- Run the same filter once left-to-right, then once right-to-left over the result. The two passes carry equal and opposite phase, so all delay cancels — which is also why it needs the whole signal in hand and can't run live.
- Passes
- Each added forward+backward pass sharpens the effective smoothing. The filter is applied twice per pass, so the rolloff is steeper than a single application — more passes means a cleaner, slower envelope.
Building the envelope, step by step
Each step adds one idea and shows a graph with only that principle applied — drawn by the real algorithm on the page's own input, working up to the finished curve.
- Step 1The carrier
Start with the raw waveform — a fast carrier whose height swells and fades. The envelope is the slow outline we want, not the fast wiggle inside it.
- Step 2Rectify
Fold the negative half upward with |x|, the way a diode does in an analog detector. Every sample is now positive, so a smoother can settle toward the amplitude instead of averaging back to zero.
- Step 3Forward then backward
Low-pass the rectified signal once left-to-right, then run the exact same filter right-to-left over the result. A single forward pass would lag behind the signal; the reversed pass carries an equal and opposite delay, so the two cancel and the finished curve sits directly on top of the swell — zero phase.
The code
Six readable forms of the exact algorithm that draws the curves above — C, JS and Python ports, an optimized C, a fixed-coefficient version, and a user-controlled one whose parameters match the sliders.
#include <math.h>
#include <stdlib.h>
/* exp(-1/tau): the one-pole coefficient for a time constant of
tau samples. Clamped at 1 so a tiny tau can't blow up. */
static double decay_coeff(double tau) {
return exp(-1.0 / (tau < 1.0 ? 1.0 : tau));
}
/* Zero-phase envelope: rectify, reflect-pad, then low-pass forward and
backward 'passes' times. The reversed pass cancels the forward phase,
so there is no lag. Needs the whole signal (offline only). */
void filtfilt_envelope(const double *x, double *env, int n,
double cut_samples, int passes) {
double a = decay_coeff(cut_samples);
double b = 1.0 - a;
int pad = (int)(cut_samples * 3 + 5);
if (pad > n - 1) pad = n - 1;
int m = n + 2 * pad;
double *y = malloc(m * sizeof(double));
double *t = malloc(m * sizeof(double));
/* reflect-pad the rectified signal into the work buffer */
for (int i = 0; i < pad; i++) {
int lo = i + 1 < n - 1 ? i + 1 : n - 1;
int hi = n - 2 - i > 0 ? n - 2 - i : 0;
y[pad - 1 - i] = fabs(x[lo]);
y[pad + n + i] = fabs(x[hi]);
}
for (int i = 0; i < n; i++) y[pad + i] = fabs(x[i]);
for (int p = 0; p < passes; p++) {
double s = y[0]; /* forward pass */
t[0] = s;
for (int i = 1; i < m; i++) {
s = a * s + b * y[i];
t[i] = s;
}
s = t[m - 1]; /* backward pass cancels the phase */
y[m - 1] = s;
for (int i = m - 2; i >= 0; i--) {
s = a * s + b * t[i];
y[i] = s;
}
}
for (int i = 0; i < n; i++) env[i] = y[pad + i];
free(y);
free(t);
}
// exp(-1/tau): one-pole coefficient for a tau-sample time constant.
const decayCoeff = (tau) => Math.exp(-1 / Math.max(1, tau));
// Zero-phase envelope: rectify, reflect-pad, then low-pass forward and
// backward 'passes' times. The reversed pass cancels the forward phase.
function filtfiltEnvelope(x, cutSamples, passes) {
const a = decayCoeff(cutSamples);
const b = 1 - a;
const n = x.length;
const pad = Math.min(n - 1, Math.round(cutSamples * 3) + 5);
const m = n + 2 * pad;
let y = new Float64Array(m);
for (let i = 0; i < pad; i++) {
y[pad - 1 - i] = Math.abs(x[Math.min(i + 1, n - 1)]);
y[pad + n + i] = Math.abs(x[Math.max(n - 2 - i, 0)]);
}
for (let i = 0; i < n; i++) y[pad + i] = Math.abs(x[i]);
for (let p = 0; p < passes; p++) {
const f = new Float64Array(m);
let s = y[0]; // forward pass
f[0] = s;
for (let i = 1; i < m; i++) { s = a * s + b * y[i]; f[i] = s; }
const g = new Float64Array(m);
s = f[m - 1]; // backward pass cancels the phase
g[m - 1] = s;
for (let i = m - 2; i >= 0; i--) { s = a * s + b * f[i]; g[i] = s; }
y = g;
}
return y.slice(pad, pad + n);
}
import math
def decay_coeff(tau):
"""One-pole coefficient exp(-1/tau) for a tau-sample time constant."""
return math.exp(-1.0 / max(1.0, tau))
def filtfilt_envelope(x, cut_samples, passes):
"""Rectify, reflect-pad, then low-pass forward and backward 'passes'
times. The reversed pass cancels the forward phase, so zero lag."""
a = decay_coeff(cut_samples)
b = 1.0 - a
n = len(x)
pad = min(n - 1, round(cut_samples * 3) + 5)
y = [0.0] * (n + 2 * pad)
for i in range(pad):
y[pad - 1 - i] = abs(x[min(i + 1, n - 1)])
y[pad + n + i] = abs(x[max(n - 2 - i, 0)])
for i in range(n):
y[pad + i] = abs(x[i])
for _ in range(passes):
f = [0.0] * len(y)
s = y[0] # forward pass
f[0] = s
for i in range(1, len(y)):
s = a * s + b * y[i]
f[i] = s
g = [0.0] * len(y)
s = f[-1] # backward pass cancels the phase
g[-1] = s
for i in range(len(y) - 2, -1, -1):
s = a * s + b * f[i]
g[i] = s
y = g
return y[pad:pad + n]
Same output, fewer ops: the update is rewritten as s += b·(v−s) — one fused multiply-add instead of two multiplies and an add; b is hoisted out of every loop; and the two pass buffers are reused in place (forward into a scratch, backward back into the source) so each pass is a single malloc-free sweep. 'restrict' lets the compiler keep s in a register.
#include <math.h>
/* in/out and scratch must not alias */
void filtfilt_envelope_opt(double *restrict y, double *restrict t,
int m, double cut_samples, int passes) {
const double a = exp(-1.0 / (cut_samples < 1 ? 1 : cut_samples));
const double b = 1.0 - a;
for (int p = 0; p < passes; p++) {
double s = y[0];
t[0] = s;
for (int i = 1; i < m; i++) {
s += b * (y[i] - s); /* one FMA */
t[i] = s;
}
s = t[m - 1];
y[m - 1] = s;
for (int i = m - 2; i >= 0; i--) {
s += b * (t[i] - s);
y[i] = s;
}
}
}
Coefficient hard-coded for cutoff = 8 samples and passes = 4 (the page defaults): a = exp(-1/8) = 0.8824969, b = 1 - a. Four passes, no tuning knobs — the smallest kernel for a known signal. Reflect-padding is assumed already applied to y (length m).
void filtfilt_envelope_fixed(double *y, double *t, int m) {
const double a = 0.8824969; /* exp(-1/8) */
const double b = 1.0 - a; /* 0.1175031 */
for (int p = 0; p < 4; p++) { /* passes = 4 */
double s = y[0];
t[0] = s;
for (int i = 1; i < m; i++) { s = a * s + b * y[i]; t[i] = s; }
s = t[m - 1];
y[m - 1] = s;
for (int i = m - 2; i >= 0; i--) { s = a * s + b * t[i]; y[i] = s; }
}
}
The two controls shape the curve. Cutoff (4–400 samp, log) sets the time constant: larger smooths harder and flattens the envelope, smaller follows finer detail. Passes (1–4) applies the forward+backward pair that many times — more passes make the roll-off steeper without ever adding lag, because every backward pass cancels its forward partner's phase. The result stays perfectly time-aligned: zero lag at any setting.
#include <math.h>
static double decay_coeff(double tau) {
return exp(-1.0 / (tau < 1.0 ? 1.0 : tau));
}
/* y is the reflect-padded rectified signal (length m); t is scratch. */
void filtfilt_envelope_ctl(double *y, double *t, int m,
int cut_samples, /* slider: 4 .. 400 */
int passes) { /* slider: 1 .. 4 */
double a = decay_coeff(cut_samples);
double b = 1.0 - a;
for (int p = 0; p < passes; p++) {
double s = y[0];
t[0] = s;
for (int i = 1; i < m; i++) { s = a * s + b * y[i]; t[i] = s; }
s = t[m - 1];
y[m - 1] = s;
for (int i = m - 2; i >= 0; i--) { s = a * s + b * t[i]; y[i] = s; }
}
}