Real-time

Real-time Hilbert

causal · FIR
time-domainlow-latencysingle-carrierpeak ≈ 1.0×

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

Real-time Hilbert
level 0.811280 samples
Hilbert refReal-time Hilbert
Latency16 samp (0.3 ms)
Output

Score card

Causality
low-latency
Signal model
single-carrier
Reads
peak ≈ 1.0×
Latency
½ buffer
Cost
FIR
Domain
time
Reads (measured on a steady sine)0.99×

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

Temporal
3%
Robust
35%
Spectral
3%
Boundary
2%

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.

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

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