Real-time
Real-time Hilbert
causal · FIRAn 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
- low-latency
- Signal model
- single-carrier
- Reads
- peak ≈ 1.0×
- Latency
- ½ buffer
- Cost
- FIR
- 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
A causal, real-time envelope from the analytic signal. The amplitude at any instant is √(x² + H{x}²), where H{x} is the carrier shifted 90° in phase. The catch: the ideal phase shifter is non-causal — its impulse response, 2/πn for odd n, reaches infinitely in both directions, so it can't be computed from past samples alone. The standard fix is a windowed FIR Hilbert transformer: truncate that response to a finite tap buffer, window it, and delay the original signal by half the buffer so the two paths line up — then take √(x² + H{x}²) on the pair. It's fully causal and runs sample-by-sample, but the output lags by half the buffer length. Here the buffer is 2·latency + 1 taps; the Latency control sets that delay directly.
The dim curve on the scope is the offline Hilbert — the same magnitude computed without any causality constraint, so it's the exact reference the real-time version is trying to match. In Real-time mode the live curve sits shifted to the right of it — that gap is the latency. Switch to Delay-compensated and it snaps back onto the reference, revealing what's actually lost: a brief warm-up error at the very start while the buffer fills, and small ripple that grows as you shorten the buffer (a shorter filter can't reproduce the slow, low-frequency part of the envelope). Longer buffer → tighter fit, more lag. That trade is the whole story of doing Hilbert in real time.
Key terms
- FIR Hilbert transformer
- A finite tap buffer that approximates the ideal 90° phase shifter. The exact shifter has an infinite impulse response 2/πn; truncating and windowing it to a fixed number of taps makes it computable in real time, at the price of an approximation that's only as good as the buffer is long.
- Latency / group delay
- The fixed delay a causal filter must impose — here, half the buffer length — before its output is valid. The Latency control sets the buffer to 2·latency + 1 taps, so a longer buffer fits the envelope better but lags further behind the signal.
- Delay compensation
- Shifting the output back by the group delay so it re-aligns with the input. It removes the visible lag and re-seats the curve on the carrier, but it can only be done after the fact — once the delayed samples have arrived — so it isn't available to a live, sample-by-sample detector.
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 causal estimate
A finite FIR can only approximate the quadrature signal, and to use its taps causally we delay the carrier to match. The magnitude √(x² + H{x}²) still traces the amplitude, but the whole curve sits shifted right by half the buffer — that gap is the latency you pay to run live.
- Step 3Delay-compensated
Shift the envelope back by the same half-buffer and it snaps onto the carrier. What remains is the true cost: a brief warm-up wobble while the buffer fills and faint ripple that grows as the buffer shortens.
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>
/* Fill h[0..L-1] with a windowed FIR Hilbert response. L = 2*lat + 1, so the
centre tap is M = lat. Ideal taps are 2/(pi*n) for odd n, zero for even n
(including the centre); a Hamming window tames the truncation ripple. */
static void hilbert_taps(double *h, int lat) {
int L = 2 * lat + 1;
int M = lat;
for (int k = 0; k < L; k++) {
int n = k - M; /* tap offset from centre */
h[k] = (n != 0 && n % 2 != 0) ? 2.0 / (M_PI * n) : 0.0;
}
for (int k = 0; k < L; k++)
h[k] *= 0.54 - 0.46 * cos(2.0 * M_PI * k / (L - 1)); /* Hamming */
}
/* Real-time Hilbert envelope: convolve x with the FIR for the quadrature
signal y, delay x by M = lat to match the filter's group delay, then take
the magnitude env = sqrt(xd^2 + y^2). Output trails the input by lat samples. */
void rt_hilbert(const double *x, double *env, int n, int lat) {
int L = 2 * lat + 1;
int M = lat;
double *h = (double *)malloc(L * sizeof(double));
hilbert_taps(h, lat);
for (int i = 0; i < n; i++) {
double y = 0.0;
int kmax = (L < i + 1) ? L : i + 1; /* fewer taps while the buffer fills */
for (int k = 0; k < kmax; k++)
y += h[k] * x[i - k];
double xd = (i - M >= 0) ? x[i - M] : 0.0;
env[i] = hypot(xd, y);
}
free(h);
}
// Windowed FIR Hilbert taps. L = 2*lat + 1, centre tap M = lat. Ideal taps are
// 2/(pi*n) for odd n, zero for even n; a Hamming window controls truncation ripple.
function hilbertTaps(lat) {
const L = 2 * lat + 1;
const M = lat;
const h = new Float64Array(L);
for (let k = 0; k < L; k++) {
const n = k - M;
h[k] = n !== 0 && n % 2 !== 0 ? 2 / (Math.PI * n) : 0;
}
for (let k = 0; k < L; k++) h[k] *= 0.54 - 0.46 * Math.cos((2 * Math.PI * k) / (L - 1));
return h;
}
// Real-time Hilbert envelope: convolve for the quadrature signal y, delay x by
// M = lat to align the paths, then env = sqrt(xd^2 + y^2). Output trails by lat.
function rtHilbert(x, lat) {
const L = 2 * lat + 1;
const M = lat;
const h = hilbertTaps(lat);
const env = new Float64Array(x.length);
for (let i = 0; i < x.length; i++) {
let y = 0;
const kmax = Math.min(L, i + 1); // fewer taps while the buffer fills
for (let k = 0; k < kmax; k++) y += h[k] * x[i - k];
const xd = i - M >= 0 ? x[i - M] : 0;
env[i] = Math.hypot(xd, y);
}
return env;
}
import numpy as np
def hilbert_taps(lat):
"""Windowed FIR Hilbert taps. L = 2*lat + 1, centre tap M = lat. Ideal taps
are 2/(pi*n) for odd n, zero for even n; Hamming tames truncation ripple."""
L = 2 * lat + 1
M = lat
n = np.arange(L) - M
h = np.where((n != 0) & (n % 2 != 0), 2.0 / (np.pi * n), 0.0)
h *= 0.54 - 0.46 * np.cos(2.0 * np.pi * np.arange(L) / (L - 1)) # Hamming
return h
def rt_hilbert(x, lat):
"""Real-time Hilbert envelope: convolve for the quadrature signal y, delay x
by M = lat to align the paths, then env = sqrt(xd^2 + y^2). Trails by lat."""
x = np.asarray(x, dtype=float)
M = lat
h = hilbert_taps(lat)
# 'full' then trim keeps y[i] = sum_k h[k]*x[i-k] (causal, taps fill in).
y = np.convolve(x, h)[: len(x)]
xd = np.concatenate([np.zeros(M), x[: len(x) - M]]) # x delayed by M
return np.hypot(xd, y)
Same output, less work. Every even-index tap is exactly zero (2/(pi*n) is defined only for odd n), so the convolution loop steps by 2 and skips them entirely — roughly half the multiply-adds. The taps are precomputed once outside the hot loop (a real-time filter builds them at setup, not per sample), and the delayed sample x[i-M] needs no math at all.
#include <math.h>
/* Build only the nonzero (odd-offset) taps once; even taps are skipped because
2/(pi*n) is zero for even n. The caller keeps h alive across all samples. */
void rt_hilbert_opt(const double *restrict x, double *restrict env,
int n, int lat, const double *restrict h) {
const int L = 2 * lat + 1;
const int M = lat;
for (int i = 0; i < n; i++) {
double y = 0.0;
const int kmax = (L < i + 1) ? L : i + 1;
/* odd offsets only: k - M odd <=> k and M differ in parity.
Start at the first such k, then stride by 2. */
for (int k = (M & 1) ? 0 : 1; k < kmax; k += 2)
y += h[k] * x[i - k];
const double xd = (i - M >= 0) ? x[i - M] : 0.0;
env[i] = hypot(xd, y);
}
}
Latency hard-coded to 16 samples (the page default), so the buffer is L = 2*16 + 1 = 33 taps and M = 16. Output is delay-compensated (the page default): a final pass pulls the envelope back by M to undo the FIR latency. The taps are still built once at startup; nothing about the geometry can change at run time.
#include <math.h>
#define LAT 16 /* page default */
#define L (2 * LAT + 1) /* 33 taps */
#define M LAT /* centre tap / matched delay */
static double h[L];
void rt_hilbert_build(void) { /* call once at startup */
for (int k = 0; k < L; k++) {
int nn = k - M;
h[k] = (nn != 0 && nn % 2 != 0) ? 2.0 / (M_PI * nn) : 0.0;
}
for (int k = 0; k < L; k++)
h[k] *= 0.54 - 0.46 * cos(2.0 * M_PI * k / (L - 1));
}
void rt_hilbert_fixed(const double *x, double *env, int n) {
for (int i = 0; i < n; i++) {
double y = 0.0;
int kmax = (L < i + 1) ? L : i + 1;
for (int k = 0; k < kmax; k++)
y += h[k] * x[i - k];
double xd = (i - M >= 0) ? x[i - M] : 0.0;
env[i] = hypot(xd, y);
}
/* delay-compensate: pull the envelope back by M to undo the FIR latency.
Reads ahead of the write index, so the forward in-place shift is safe. */
for (int i = 0; i < n; i++)
env[i] = (i + M < n) ? env[i + M] : env[n - 1];
}
The Latency slider (4-200 samples) sets lat directly, which fixes both the buffer length L = 2*lat + 1 and the matched delay M = lat. Longer buffer = tighter fit to the true envelope but more lag; shorter buffer = less lag but visible ripple, because a short filter can't reproduce the slow, low-frequency part of the envelope. The page's Delay-comp. option is the optional comp pass below.
#include <math.h>
static void hilbert_taps(double *h, int lat) {
int L = 2 * lat + 1, M = lat;
for (int k = 0; k < L; k++) {
int nn = k - M;
h[k] = (nn != 0 && nn % 2 != 0) ? 2.0 / (M_PI * nn) : 0.0;
}
for (int k = 0; k < L; k++)
h[k] *= 0.54 - 0.46 * cos(2.0 * M_PI * k / (L - 1));
}
void rt_hilbert_ctl(const double *x, double *env, int n,
int lat, /* slider: 4 .. 200 samples */
int comp) { /* Output: 0 = as it arrives, 1 = delay-comp. */
int L = 2 * lat + 1, M = lat;
double *h = (double *)malloc(L * sizeof(double));
hilbert_taps(h, lat);
for (int i = 0; i < n; i++) {
double y = 0.0;
int kmax = (L < i + 1) ? L : i + 1;
for (int k = 0; k < kmax; k++)
y += h[k] * x[i - k];
double xd = (i - M >= 0) ? x[i - M] : 0.0;
env[i] = hypot(xd, y);
}
free(h);
if (comp) { /* shift the envelope back by M to undo the lag */
for (int i = 0; i < n; i++)
env[i] = (i + M < n) ? env[i + M] : env[n - 1];
}
}
