Offline
Peak interp
fit-firstAn 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
- peak ≈ 1.0×
- Latency
- none (offline)
- Cost
- moderate
- 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 tightest raw fit — a curve drawn straight through the peaks. Detect the local maxima of |x|, then interpolate between them. Linear connects the dots; PCHIP (monotone cubic) curves smoothly without overshooting; cubic spline is smoothest but rings — it overshoots peaks and can dip below zero.
This is the detector to reach for when fidelity to the waveform's outline matters most, e.g. for EMD or display. Its weakness is the peak picking: too large a minimum distance and it skips real peaks.
Key terms
- Local maximum / peak picking
- A sample larger than its neighbours in |x| — a tip of the rectified waveform. These are the knots the envelope is drawn through; picking them well is the whole job.
- Minimum peak distance
- The control that suppresses any peak sitting closer than a set spacing to a taller one, so a single swell yields one knot instead of a cluster. Set it too large and it starts skipping real peaks, flattening the outline.
- Interpolation kind
- How the curve joins the knots. Linear connects the dots with straight segments; PCHIP (monotone cubic) curves smoothly without overshooting; a cubic spline is smoothest of all but rings.
- Overshoot / ringing
- A spline curving past the peaks between knots — bulging above the tips and even dipping below zero where the signal never went. The price of the smoothest fit.
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, the shape that touches the tips of the waveform.
- Step 2Rectify
Fold the negative half upward with |x|. Every sample is now positive, so the upper edge of the waveform — the peaks we are about to pick — is one consistent set of local maxima rather than two.
- Step 3Pick peaks and interpolate
Find every local maximum of the rectified signal (suppressing peaks closer than the minimum distance, keeping the taller), then draw a smooth curve through those knots. Here PCHIP connects them — the fitted outline rides exactly on the peak tips.
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>
/* Local maxima of the rectified signal, with min-distance suppression: when two
peaks sit closer than min_dist, keep only the taller. Returns the count and
fills idx[]. */
static int find_peaks(const double *r, int n, int min_dist, int *idx) {
if (min_dist < 1) min_dist = 1;
int k = 0;
for (int i = 1; i < n - 1; i++) {
if (r[i] >= r[i - 1] && r[i] > r[i + 1]) { /* a local max */
if (k && i - idx[k - 1] < min_dist) {
if (r[i] > r[idx[k - 1]]) idx[k - 1] = i; /* taller wins */
} else {
idx[k++] = i;
}
}
}
return k;
}
/* Peak interpolation (linear): rectify, find peaks, then draw straight segments
through the knots [0, ...peaks, n-1]. The ends hold flat (t clamped to [0,1]).
PCHIP and natural-cubic swap in here, replacing only this segment formula. */
void peak_interp_linear(const double *x, double *env, int n, int min_dist) {
double *r = (double *)malloc(n * sizeof(double));
int *pk = (int *)malloc(n * sizeof(int));
for (int i = 0; i < n; i++) r[i] = fabs(x[i]);
int np = find_peaks(r, n, min_dist, pk);
/* knots: 0, the peaks, n-1 (skip any that coincide) */
int *xs = (int *)malloc((np + 2) * sizeof(int));
int m = 0;
xs[m++] = 0;
for (int i = 0; i < np; i++) if (pk[i] != xs[m - 1]) xs[m++] = pk[i];
if (n - 1 != xs[m - 1]) xs[m++] = n - 1;
/* sweep segment k forward as i advances; linear-interpolate within it */
int k = 0;
for (int i = 0; i < n; i++) {
while (k < m - 2 && xs[k + 1] < i) k++;
int x0 = xs[k], x1 = xs[k + 1];
double t = (x1 == x0) ? 0.0 : (double)(i - x0) / (x1 - x0);
if (t < 0) t = 0; else if (t > 1) t = 1;
env[i] = r[x0] + (r[x1] - r[x0]) * t;
}
free(r); free(pk); free(xs);
}
// Local maxima of |x|, with min-distance suppression: of two peaks closer than
// minDist, keep only the taller one.
function findPeaks(r, minDist) {
minDist = Math.max(1, Math.round(minDist));
const kept = [];
for (let i = 1; i < r.length - 1; i++) {
if (r[i] >= r[i - 1] && r[i] > r[i + 1]) { // a local max
const last = kept[kept.length - 1];
if (kept.length && i - last < minDist) {
if (r[i] > r[last]) kept[kept.length - 1] = i; // taller wins
} else {
kept.push(i);
}
}
}
return kept;
}
// Peak interpolation (linear): rectify, find peaks, then straight segments through
// the knots [0, ...peaks, n-1]. The ends hold flat (t clamped to [0,1]).
// PCHIP and natural-cubic reuse findPeaks and replace only this segment formula.
function peakInterpLinear(x, minDist) {
const n = x.length;
const r = new Float64Array(n);
for (let i = 0; i < n; i++) r[i] = Math.abs(x[i]);
let pk = findPeaks(r, minDist);
if (pk.length === 0) pk = [0];
const raw = [0, ...pk, n - 1];
const xs = [];
for (let i = 0; i < raw.length; i++) {
if (i === 0 || raw[i] !== raw[i - 1]) xs.push(raw[i]);
}
const env = new Float64Array(n);
let k = 0;
for (let i = 0; i < n; i++) {
while (k < xs.length - 2 && xs[k + 1] < i) k++;
const x0 = xs[k];
const x1 = xs[k + 1];
const t = x1 === x0 ? 0 : (i - x0) / (x1 - x0);
const ct = Math.max(0, Math.min(1, t));
env[i] = r[x0] + (r[x1] - r[x0]) * ct;
}
return env;
}
def find_peaks(r, min_dist):
"""Local maxima of |x|, with min-distance suppression: of two peaks closer
than min_dist, keep only the taller."""
min_dist = max(1, round(min_dist))
kept = []
for i in range(1, len(r) - 1):
if r[i] >= r[i - 1] and r[i] > r[i + 1]: # a local max
if kept and i - kept[-1] < min_dist:
if r[i] > r[kept[-1]]:
kept[-1] = i # taller wins
else:
kept.append(i)
return kept
def peak_interp_linear(x, min_dist):
"""Rectify, find peaks, then straight segments through [0, ...peaks, n-1].
The ends hold flat (t clamped to [0, 1]). PCHIP and natural-cubic reuse
find_peaks and replace only the within-segment formula."""
n = len(x)
r = [abs(v) for v in x]
pk = find_peaks(r, min_dist) or [0]
raw = [0, *pk, n - 1]
xs = []
for i, v in enumerate(raw):
if i == 0 or v != raw[i - 1]:
xs.append(v)
env = [0.0] * n
k = 0
for i in range(n):
while k < len(xs) - 2 and xs[k + 1] < i:
k += 1
x0, x1 = xs[k], xs[k + 1]
t = 0.0 if x1 == x0 else (i - x0) / (x1 - x0)
t = max(0.0, min(1.0, t))
env[i] = r[x0] + (r[x1] - r[x0]) * t
return env
Optimizes the linear variant; PCHIP and natural-cubic share this exact peak-finding front end and differ only in the segment formula. Three changes, same output: one fused pass folds the rectify into the peak scan so |x| is computed once per sample; the knot ends are appended without a temporary array; and the resampling loop hoists the segment endpoints (x0, x1, y0, dy, inverse span) out of the inner work so the hot loop is a single multiply-add per sample, with no per-i branch on the clamp until the segment changes.
#include <math.h>
/* Rectify and find peaks in one pass; idx[] receives the kept maxima. */
static int rectify_find_peaks(const double *restrict x, double *restrict r,
int n, int min_dist, int *restrict idx) {
if (min_dist < 1) min_dist = 1;
r[0] = fabs(x[0]);
r[1] = fabs(x[1]);
int k = 0;
for (int i = 1; i < n - 1; i++) {
r[i + 1] = fabs(x[i + 1]);
if (r[i] >= r[i - 1] && r[i] > r[i + 1]) {
if (k && i - idx[k - 1] < min_dist) {
if (r[i] > r[idx[k - 1]]) idx[k - 1] = i;
} else {
idx[k++] = i;
}
}
}
return k;
}
void peak_interp_linear_opt(const double *restrict x, double *restrict env,
int n, int min_dist) {
double *r = (double *)malloc(n * sizeof(double));
int *pk = (int *)malloc(n * sizeof(int));
int *xs = (int *)malloc((n + 2) * sizeof(int));
int np = rectify_find_peaks(x, r, n, min_dist, pk);
int m = 0;
xs[m++] = 0;
for (int i = 0; i < np; i++) if (pk[i] != xs[m - 1]) xs[m++] = pk[i];
if (n - 1 != xs[m - 1]) xs[m++] = n - 1;
int i = 0;
for (int k = 0; k < m - 1; k++) {
int x0 = xs[k], x1 = xs[k + 1];
double y0 = r[x0], dy = r[x1] - y0;
double inv = (x1 == x0) ? 0.0 : 1.0 / (x1 - x0);
for (; i <= x1 && i < n; i++) /* fill this segment */
env[i] = y0 + dy * ((i - x0) * inv);
}
for (; i < n; i++) env[i] = r[xs[m - 1]]; /* flat tail */
free(r); free(pk); free(xs);
}
Min peak distance hard-coded to 8 samples and interpolation kind hard-coded to LINEAR (the page defaults are dist = 8 and kind = PCHIP; this fixed kernel uses the simplest interpolant). No tuning knobs — the smallest peak-pick-and-connect kernel for a known signal.
#include <math.h>
#define MIN_DIST 8 /* page default: 8 samples */
/* Local maxima of |x|, keeping the taller of any pair closer than MIN_DIST. */
static int find_peaks(const double *r, int n, int *idx) {
int k = 0;
for (int i = 1; i < n - 1; i++) {
if (r[i] >= r[i - 1] && r[i] > r[i + 1]) {
if (k && i - idx[k - 1] < MIN_DIST) {
if (r[i] > r[idx[k - 1]]) idx[k - 1] = i;
} else {
idx[k++] = i;
}
}
}
return k;
}
/* Linear peak interpolation with the distance fixed. */
void peak_interp_fixed(const double *x, double *env, int n) {
double *r = (double *)malloc(n * sizeof(double));
int *pk = (int *)malloc(n * sizeof(int));
int *xs = (int *)malloc((n + 2) * sizeof(int));
for (int i = 0; i < n; i++) r[i] = fabs(x[i]);
int np = find_peaks(r, n, pk);
int m = 0;
xs[m++] = 0;
for (int i = 0; i < np; i++) if (pk[i] != xs[m - 1]) xs[m++] = pk[i];
if (n - 1 != xs[m - 1]) xs[m++] = n - 1;
int k = 0;
for (int i = 0; i < n; i++) {
while (k < m - 2 && xs[k + 1] < i) k++;
int x0 = xs[k], x1 = xs[k + 1];
double t = (x1 == x0) ? 0.0 : (double)(i - x0) / (x1 - x0);
if (t < 0) t = 0; else if (t > 1) t = 1;
env[i] = r[x0] + (r[x1] - r[x0]) * t;
}
free(r); free(pk); free(xs);
}
Min peak distance maps straight onto the page's slider (4–80 samples, log; default 11). Larger min_dist forces the picker to skip closely-spaced peaks, so the fitted upper envelope rides over fine ripple and tracks only the broad outline; smaller min_dist hugs every local maximum, following the waveform tightly. The interpolation kind (PCHIP / Cubic / Linear) is a separate page control: it changes the curve drawn between the same peaks — Linear connects them with straight segments, PCHIP curves smoothly without overshooting, Cubic is smoothest but can ring above the peaks and dip below zero. This kernel uses the Linear connector; PCHIP and natural-cubic reuse find_peaks and swap only the segment formula.
#include <math.h>
static int find_peaks(const double *r, int n, int min_dist, int *idx) {
if (min_dist < 1) min_dist = 1;
int k = 0;
for (int i = 1; i < n - 1; i++) {
if (r[i] >= r[i - 1] && r[i] > r[i + 1]) {
if (k && i - idx[k - 1] < min_dist) {
if (r[i] > r[idx[k - 1]]) idx[k - 1] = i;
} else {
idx[k++] = i;
}
}
}
return k;
}
void peak_interp_ctl(const double *x, double *env, int n,
int min_dist) { /* slider: 4 .. 80 samples (log) */
double *r = (double *)malloc(n * sizeof(double));
int *pk = (int *)malloc(n * sizeof(int));
int *xs = (int *)malloc((n + 2) * sizeof(int));
for (int i = 0; i < n; i++) r[i] = fabs(x[i]);
int np = find_peaks(r, n, min_dist, pk);
int m = 0;
xs[m++] = 0;
for (int i = 0; i < np; i++) if (pk[i] != xs[m - 1]) xs[m++] = pk[i];
if (n - 1 != xs[m - 1]) xs[m++] = n - 1;
int k = 0;
for (int i = 0; i < n; i++) {
while (k < m - 2 && xs[k + 1] < i) k++;
int x0 = xs[k], x1 = xs[k + 1];
double t = (x1 == x0) ? 0.0 : (double)(i - x0) / (x1 - x0);
if (t < 0) t = 0; else if (t > 1) t = 1;
env[i] = r[x0] + (r[x1] - r[x0]) * t;
}
free(r); free(pk); free(xs);
}