Offline
Hilbert
exactAn 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
- offline
- Signal model
- single-carrier
- Reads
- exact
- Latency
- none (offline)
- Cost
- FFT
- 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
The mathematically exact envelope — no time constant, no ripple. Build the analytic signal (the original plus j times its Hilbert transform) and take its magnitude √(x² + H{x}²). There is no attack/release to tune and nothing to smooth: for a clean tone it returns the amplitude exactly, at every sample.
The catch is that the ideal transform is non-causal — computed here over the whole signal by FFT. A real-time FIR approximation exists but adds latency. Optional smoothing only cleans up wobble on broadband material; on a clean carrier the raw magnitude is already smooth.
Key terms
- Analytic signal
- The original signal plus j times its Hilbert transform — a complex signal whose magnitude is the instantaneous amplitude at every sample.
- Hilbert transform
- A 90° phase shift of every frequency component, written H{x}. Pairing it with the original is what makes the analytic signal.
- Instantaneous amplitude
- The magnitude √(x² + H{x}²) — the exact envelope, defined at every sample with no smoothing or time constant.
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 2The analytic magnitude
Pair the signal with a 90°-phase-shifted copy of itself — its Hilbert transform — to form the analytic signal. Its magnitude is the same above and below zero, so it traces the carrier's amplitude from both sides at once, with no rectifier ripple to smooth away.
- Step 3The exact envelope
Keep the upper magnitude and you have the instantaneous amplitude directly — a smooth line that sits exactly on the carrier's peaks at every sample, with no time constant chosen and nothing averaged away.
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>
/* Shared transform (see math.ts fft): in-place radix-2 Cooley-Tukey on the
complex buffer re[]/im[] of length m (a power of two). inv != 0 runs the
inverse with 1/m scaling. Not re-derived here — the Hilbert steps are the point. */
void fft(double *re, double *im, int inv);
/* Hilbert envelope: |analytic signal| via FFT. n need not be a power of two;
we pad up to m. The caller provides scratch re[]/im[] of length m. */
void hilbert_envelope(const double *x, double *env, int n) {
int m = 1;
while (m < n) m <<= 1;
double *re = (double *)calloc(m, sizeof(double));
double *im = (double *)calloc(m, sizeof(double));
for (int i = 0; i < n; i++) re[i] = x[i]; /* copy signal, im[] stays 0 */
fft(re, im, 0);
/* Build the analytic spectrum: keep DC (0) and Nyquist (m/2) as-is,
double the positive frequencies, zero the negative ones. */
int half = m / 2;
for (int i = 1; i < half; i++) { re[i] *= 2.0; im[i] *= 2.0; }
for (int i = half + 1; i < m; i++) { re[i] = 0.0; im[i] = 0.0; }
fft(re, im, 1); /* back to time domain */
for (int i = 0; i < n; i++) /* envelope = |analytic| */
env[i] = hypot(re[i], im[i]);
free(re);
free(im);
}
// Shared transform (math.ts): in-place radix-2 FFT on re/im (length a power of
// two). inv === true runs the inverse with 1/n scaling. Re-used, not re-derived.
import { fft } from "./math";
// Hilbert envelope: |analytic signal| via FFT.
function hilbertEnvelope(x) {
let m = 1;
while (m < x.length) m <<= 1;
const re = new Float64Array(m);
const im = new Float64Array(m);
for (let i = 0; i < x.length; i++) re[i] = x[i]; // copy signal, im stays 0
fft(re, im, false);
// Analytic spectrum: DC and Nyquist unchanged, positive freqs ×2, negatives ×0.
const half = m / 2;
for (let i = 1; i < half; i++) { re[i] *= 2; im[i] *= 2; }
for (let i = half + 1; i < m; i++) { re[i] = 0; im[i] = 0; }
fft(re, im, true); // back to time domain
const env = new Float64Array(x.length);
for (let i = 0; i < x.length; i++) env[i] = Math.hypot(re[i], im[i]);
return env;
}
import numpy as np
from scipy.signal import hilbert
def hilbert_envelope(x):
"""Exact instantaneous envelope: magnitude of the analytic signal.
scipy.signal.hilbert returns the analytic signal x + j*H{x} directly
(same FFT recipe: positive freqs doubled, negatives zeroed), so the
envelope is just its magnitude.
"""
analytic = hilbert(np.asarray(x, dtype=float))
return np.abs(analytic)
The cost here is dominated by the FFT, so the honest optimization is in the transform, not the wrapper: feed a real-input FFT (rfft) instead of stuffing the signal into a full complex buffer. An rfft returns only the m/2+1 non-negative-frequency bins, halving the transform work and the memory traffic. The analytic-signal scaling then becomes trivial — every returned bin except DC and Nyquist is a positive frequency, so they all get ×2 — and an inverse complex FFT of the doubled half-spectrum gives the analytic signal. Use a tuned in-place radix-2 (or a library like FFTW/pffft) for the actual butterflies. The bin-scaling recipe is unchanged.
#include <math.h>
#include <complex.h>
/* Real-input forward FFT: x[n] -> spec[0 .. m/2] (non-negative freqs only). */
void rfft(const double *x, int n, int m, double complex *spec);
/* Inverse complex FFT of a full m-length spectrum, with 1/m scaling. */
void ifft(double complex *spec, int m);
void hilbert_envelope_opt(const double *x, double *env, int n) {
int m = 1;
while (m < n) m <<= 1;
int half = m / 2;
double complex *spec = malloc(m * sizeof(double complex));
rfft(x, n, m, spec); /* half the transform of a complex FFT */
/* Analytic scaling: positive freqs ×2, DC and Nyquist ×1, negatives = 0. */
for (int i = 1; i < half; i++) spec[i] *= 2.0;
for (int i = half + 1; i < m; i++) spec[i] = 0.0;
ifft(spec, m); /* analytic signal in spec[0 .. n-1] */
for (int i = 0; i < n; i++) env[i] = cabs(spec[i]);
free(spec);
}
Smoothing is hard-coded to the page default of 0 — i.e. off. The envelope is the raw |analytic signal| with no post-filtering, which is the exact instantaneous amplitude. (Smoothing only matters for broadband material; for a clean tone the raw magnitude is already smooth.) This is the smallest possible kernel: one forward FFT, the fixed bin-scaling, one inverse FFT, magnitude.
#include <math.h>
void fft(double *re, double *im, int inv); /* shared transform (math.ts) */
/* Fixed: smoothing = 0 (page default). Envelope is the bare |analytic|. */
void hilbert_envelope_fixed(const double *x, double *env, int n) {
int m = 1;
while (m < n) m <<= 1;
double *re = (double *)calloc(m, sizeof(double));
double *im = (double *)calloc(m, sizeof(double));
for (int i = 0; i < n; i++) re[i] = x[i];
fft(re, im, 0);
int half = m / 2;
for (int i = 1; i < half; i++) { re[i] *= 2.0; im[i] *= 2.0; }
for (int i = half + 1; i < m; i++) { re[i] = 0.0; im[i] = 0.0; }
fft(re, im, 1);
for (int i = 0; i < n; i++) env[i] = hypot(re[i], im[i]);
free(re);
free(im);
}
The Smoothing slider (0-300 samples) feeds smooth_n. At 0 the envelope is the exact |analytic| — no filtering. Above 0 it sets the cutoff of a zero-phase low-pass (a one-pole run forward then backward, so no time shift): larger values average over a wider window and flatten wobble on broadband material, at the cost of rounding off fast amplitude changes. The Hilbert transform itself is untouched; this only post-smooths its magnitude.
#include <math.h>
void fft(double *re, double *im, int inv); /* shared transform (math.ts) */
/* Zero-phase one-pole low-pass (forward then backward) with a cut_n-sample
time constant — the shared filtfilt smoother. */
void filtfilt(double *env, int n, int cut_n, int passes);
void hilbert_envelope_ctl(const double *x, double *env, int n,
int smooth_n) { /* slider: 0 .. 300 samples */
int m = 1;
while (m < n) m <<= 1;
double *re = (double *)calloc(m, sizeof(double));
double *im = (double *)calloc(m, sizeof(double));
for (int i = 0; i < n; i++) re[i] = x[i];
fft(re, im, 0);
int half = m / 2;
for (int i = 1; i < half; i++) { re[i] *= 2.0; im[i] *= 2.0; }
for (int i = half + 1; i < m; i++) { re[i] = 0.0; im[i] = 0.0; }
fft(re, im, 1);
for (int i = 0; i < n; i++) env[i] = hypot(re[i], im[i]);
if (smooth_n > 0) filtfilt(env, n, smooth_n, 1); /* 0 = exact, no smoothing */
free(re);
free(im);
}