Real-time
TKEO
3-sampleAn 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
- real-time
- Signal model
- single-carrier
- Reads
- peak ≈ 1.0×
- Latency
- ≈1 sample
- Cost
- trivial
- 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
Near-instant amplitude from just three samples. The Teager–Kaiser energy operator ψ[n] = x[n]² − x[n−1]·x[n+1] estimates the signal's energy A²ω² almost without delay; take the root and rescale by the carrier frequency to recover amplitude.
It is astonishingly fast and cheap and reacts in a couple of samples — but it is noisy, assumes a single narrowband tone, and needs to know (or estimate) the carrier to get the scale right. Light smoothing tames the noise.
Key terms
- Teager–Kaiser energy operator (TKEO)
- The three-sample estimate ψ[n] = x[n]² − x[n−1]·x[n+1]. It reads the signal's energy A²ω² from one sample and its two neighbours, with almost no delay — the whole method rests on this one line.
- Instantaneous energy
- What the operator actually tracks: a quantity that rises with both amplitude and frequency, not amplitude alone. Taking the square root of it recovers an amplitude-related value, which is why the build follows the energy step with a root.
- Carrier frequency scaling
- Because the output carries A²ω², the frequency term is baked in. To rescale back to a true amplitude A you must know or estimate the carrier frequency ω and divide it out.
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 2Teager energy
Run the three-sample operator x[n]² − x[n−1]·x[n+1] (clamped at zero). It reads the instantaneous energy of the carrier — large where the wave is tall, near zero where it is small — but it is jagged, pulsing twice per cycle.
- Step 3Smooth and rescale
Average the energy with a short centered mean, take its square root, and divide by sin(2π/CAR) to turn energy back into amplitude. The jitter melts into a fast, low-lag envelope.
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>
/* Centered (zero-lag) moving mean of width w samples. */
static void centered_mean(const double *x, double *out, int n, int w) {
if (w < 1) w = 1;
int half = w / 2;
for (int i = 0; i < n; i++) {
int lo = i - half < 0 ? 0 : i - half;
int hi = i + half + 1 > n ? n : i + half + 1;
double s = 0.0;
for (int j = lo; j < hi; j++) s += x[j];
out[i] = s / (hi - lo);
}
}
/* TKEO envelope: psi[n] = x[n]^2 - x[n-1]*x[n+1] (clamped at 0, ends copied),
smoothed, then sqrt and rescaled by the carrier so energy reads as amplitude. */
void tkeo_envelope(const double *x, double *env, int n,
int smooth_samples, double carrier_samples) {
double *psi = malloc(n * sizeof(double));
for (int i = 1; i < n - 1; i++) {
double v = x[i] * x[i] - x[i - 1] * x[i + 1];
psi[i] = v > 0.0 ? v : 0.0;
}
psi[0] = psi[1];
psi[n - 1] = psi[n - 2];
double *sm = malloc(n * sizeof(double));
centered_mean(psi, sm, n, smooth_samples);
double s = sin(2.0 * M_PI / carrier_samples);
for (int i = 0; i < n; i++) env[i] = sqrt(sm[i]) / s;
free(psi);
free(sm);
}
// Centered (zero-lag) moving mean of width w samples.
function centeredMean(x, w) {
w = Math.max(1, Math.round(w));
const half = w >> 1;
const n = x.length;
const out = new Float64Array(n);
for (let i = 0; i < n; i++) {
const lo = Math.max(0, i - half);
const hi = Math.min(n, i + half + 1);
let s = 0;
for (let j = lo; j < hi; j++) s += x[j];
out[i] = s / (hi - lo);
}
return out;
}
// TKEO envelope: psi[n] = x[n]^2 - x[n-1]*x[n+1] (clamped, ends copied),
// smoothed, then sqrt rescaled by the carrier to recover amplitude.
function tkeoEnvelope(x, smoothSamples, carrierSamples) {
const n = x.length;
const psi = new Float64Array(n);
for (let i = 1; i < n - 1; i++) {
const v = x[i] * x[i] - x[i - 1] * x[i + 1];
psi[i] = v > 0 ? v : 0;
}
psi[0] = psi[1];
psi[n - 1] = psi[n - 2];
const sm = centeredMean(psi, smoothSamples);
const s = Math.sin((2 * Math.PI) / carrierSamples);
const env = new Float64Array(n);
for (let i = 0; i < n; i++) env[i] = Math.sqrt(sm[i]) / s;
return env;
}
import math
def centered_mean(x, w):
"""Centered (zero-lag) moving mean of width w samples."""
w = max(1, round(w))
half = w // 2
n = len(x)
out = [0.0] * n
for i in range(n):
lo = max(0, i - half)
hi = min(n, i + half + 1)
out[i] = sum(x[lo:hi]) / (hi - lo)
return out
def tkeo_envelope(x, smooth_samples, carrier_samples):
"""psi[n] = x[n]^2 - x[n-1]*x[n+1] (clamped, ends copied), smoothed,
then sqrt rescaled by the carrier to recover amplitude."""
n = len(x)
psi = [0.0] * n
for i in range(1, n - 1):
v = x[i] * x[i] - x[i - 1] * x[i + 1]
psi[i] = v if v > 0 else 0.0
psi[0] = psi[1]
psi[n - 1] = psi[n - 2]
sm = centered_mean(psi, smooth_samples)
s = math.sin(2 * math.pi / carrier_samples)
return [math.sqrt(v) / s for v in sm]
Same output, no per-sample allocation or re-summing. The centered mean runs as a sliding window: a running sum adds the entering sample and drops the leaving one, turning the inner averaging loop O(n·w) into O(n). The carrier scale 1/sin(2π/CAR) is precomputed once so the final pass is a sqrt and a multiply, and `restrict` lets the compiler keep the running sum in a register.
#include <math.h>
void tkeo_envelope_opt(const double *restrict x, double *restrict env,
int n, int smooth_samples, double carrier_samples) {
double *psi = malloc(n * sizeof(double));
for (int i = 1; i < n - 1; i++) {
double v = x[i] * x[i] - x[i - 1] * x[i + 1];
psi[i] = v > 0.0 ? v : 0.0;
}
psi[0] = psi[1];
psi[n - 1] = psi[n - 2];
int w = smooth_samples < 1 ? 1 : smooth_samples;
int half = w / 2;
const double inv_s = 1.0 / sin(2.0 * M_PI / carrier_samples);
double sum = 0.0;
int lo = 0, hi = 0; /* current window is [lo, hi) */
for (int i = 0; i < n; i++) {
int nlo = i - half < 0 ? 0 : i - half;
int nhi = i + half + 1 > n ? n : i + half + 1;
while (hi < nhi) sum += psi[hi++];
while (lo < nlo) sum -= psi[lo++];
env[i] = sqrt(sum / (hi - lo)) * inv_s;
}
free(psi);
}
Smoothing hard-coded to 4 samples (the page default) and the carrier fixed at CAR = 22 samples/cycle, so the rescale 1/sin(2π/22) collapses to a single constant. No tuning knobs — the smallest kernel for a known carrier.
#include <math.h>
#define SMOOTH 4 /* centered-mean width, samples */
#define INV_SIN 3.518897 /* 1 / sin(2*pi/22), carrier = 22 samp/cycle */
void tkeo_envelope_fixed(const double *x, double *env, int n) {
double *psi = malloc(n * sizeof(double));
for (int i = 1; i < n - 1; i++) {
double v = x[i] * x[i] - x[i - 1] * x[i + 1];
psi[i] = v > 0.0 ? v : 0.0;
}
psi[0] = psi[1];
psi[n - 1] = psi[n - 2];
int half = SMOOTH / 2;
for (int i = 0; i < n; i++) {
int lo = i - half < 0 ? 0 : i - half;
int hi = i + half + 1 > n ? n : i + half + 1;
double s = 0.0;
for (int j = lo; j < hi; j++) s += psi[j];
env[i] = sqrt(s / (hi - lo)) * INV_SIN;
}
free(psi);
}
One slider on the page: Smoothing (2–256 samp, default 48), the width of the centered moving mean over ψ. Larger values average more of the operator's three-sample jitter into a calmer, more lagging curve; smaller values track AM and onsets faster but let the raw TKEO noise through. The carrier (CAR = 22 samp/cycle) is fixed by the signal model, not a control — it only sets the amplitude scale.
#include <math.h>
void tkeo_envelope_ctl(const double *x, double *env, int n,
int smooth_samples) { /* slider: 2 .. 256 */
double *psi = malloc(n * sizeof(double));
for (int i = 1; i < n - 1; i++) {
double v = x[i] * x[i] - x[i - 1] * x[i + 1];
psi[i] = v > 0.0 ? v : 0.0;
}
psi[0] = psi[1];
psi[n - 1] = psi[n - 2];
int w = smooth_samples < 2 ? 2 : smooth_samples;
int half = w / 2;
const double inv_s = 1.0 / sin(2.0 * M_PI / 22.0); /* carrier = 22 samp */
for (int i = 0; i < n; i++) {
int lo = i - half < 0 ? 0 : i - half;
int hi = i + half + 1 > n ? n : i + half + 1;
double s = 0.0;
for (int j = lo; j < hi; j++) s += psi[j];
env[i] = sqrt(s / (hi - lo)) * inv_s;
}
free(psi);
}
