Offline

Zero-phase

filtfilt
time-domainzero-lagsingle-carriermean-rect ≈ 0.637×

An 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

Zero-phase
level 0.511280 samples
Hilbert refZero-phase
Cutoff8 samp (0.2 ms)
Passes

Score card

Causality
zero-lag
Signal model
single-carrier
Reads
mean-rect ≈ 0.637×
Latency
none (zero-lag)
Cost
moderate
Domain
time
Reads (measured on a steady sine)0.63×

Tracking error vs the true envelope, by challenge axis — longer bar is a tighter fit. Computed live across the oracle generators.

Temporal
12%
Robust
25%
Spectral
5%
Boundary
31%

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.

  1. 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.

  2. 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.

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