Real-time
Peak-hold
analog-styleAn 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
- none up · decay down
- 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
Catches every transient instantly, then bleeds down. Attack is zero — the output jumps to each new peak the instant it arrives — optionally holds for a set number of samples, then decays exponentially.
This is the behavior of analog diode detectors and PPM meters: superb for catching brief spikes a smoother would miss, but the held line is jagged and is a poor control signal for anything that wants smooth gain.
Key terms
- Instant attack
- The output jumps to each new peak the moment it arrives — zero rise time. There is no smoothing on the way up, so even a single-sample spike is captured and no transient is missed.
- Hold time
- After a peak, the level is held flat for a set number of samples before it starts to fall. The plateau keeps a brief crest visible long enough to read.
- Exponential decay
- Once the hold expires the level bleeds down by a fixed fraction per sample: y *= decay. The fall is fast at first and slows as it approaches zero, tracing the familiar sag between peaks.
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 2Rectify
Fold the negative half upward with |x|, the way a diode does in an analog detector. Every sample is now positive, so the detector can chase the amplitude instead of the signed waveform.
- Step 3Capture and decay
Whenever a rectified sample rises above the current line, jump instantly to it — zero attack. Below it, the line bleeds down exponentially. The result is a sawtooth that pins each crest exactly and sags between them.
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>
/* exp(-1/tau): the one-pole coefficient for a time constant of
tau samples. Clamped at 1 so a tiny tau can't blow up. */
static double decay_coeff(double tau) {
return exp(-1.0 / (tau < 1.0 ? 1.0 : tau));
}
/* Peak-hold: rectify, jump instantly to each new peak and arm the hold
counter, count the hold down while below the peak, then decay. */
void peak_hold(const double *x, double *env, int n,
double decay_samples, int hold_samples) {
double dec = decay_coeff(decay_samples);
if (hold_samples < 0) hold_samples = 0;
double e = 0.0;
int cnt = 0;
for (int i = 0; i < n; i++) {
double r = fabs(x[i]);
if (r >= e) { /* new peak: instant attack, arm hold */
e = r;
cnt = hold_samples;
} else if (cnt > 0) { /* holding the level */
cnt--;
} else { /* released: exponential decay */
e *= dec;
}
env[i] = e;
}
}
// exp(-1/tau): one-pole coefficient for a tau-sample time constant.
const decayCoeff = (tau) => Math.exp(-1 / Math.max(1, tau));
// Peak-hold: rectify, jump to each new peak and arm the hold counter,
// count down while below the peak, then decay.
function peakHold(x, decaySamples, holdSamples) {
const dec = decayCoeff(decaySamples);
holdSamples = Math.max(0, Math.round(holdSamples));
const env = new Float64Array(x.length);
let e = 0;
let cnt = 0;
for (let i = 0; i < x.length; i++) {
const r = Math.abs(x[i]);
if (r >= e) { // new peak: instant attack, arm hold
e = r;
cnt = holdSamples;
} else if (cnt > 0) { // holding the level
cnt--;
} else { // released: exponential decay
e *= dec;
}
env[i] = e;
}
return env;
}
import math
def decay_coeff(tau):
"""One-pole coefficient exp(-1/tau) for a tau-sample time constant."""
return math.exp(-1.0 / max(1.0, tau))
def peak_hold(x, decay_samples, hold_samples):
"""Rectify, jump to each new peak and arm the hold counter, count down
while below the peak, then decay."""
dec = decay_coeff(decay_samples)
hold_samples = max(0, round(hold_samples))
env = [0.0] * len(x)
e = 0.0
cnt = 0
for i, xi in enumerate(x):
r = abs(xi)
if r >= e: # new peak: instant attack, arm hold
e = r
cnt = hold_samples
elif cnt > 0: # holding the level
cnt -= 1
else: # released: exponential decay
e *= dec
env[i] = e
return env
Same output, no per-sample divides: decay_coeff is evaluated once before the loop, and `restrict` tells the compiler x and env don't alias so it can keep e and cnt in registers. The hot path stays branchy because the three cases (new peak / hold / decay) are genuinely data-dependent.
#include <math.h>
void peak_hold_opt(const double *restrict x, double *restrict env,
int n, double decay_samples, int hold_samples) {
const double dec = exp(-1.0 / (decay_samples < 1 ? 1 : decay_samples));
if (hold_samples < 0) hold_samples = 0;
double e = 0.0;
int cnt = 0;
for (int i = 0; i < n; i++) {
double r = fabs(x[i]);
if (r >= e) { e = r; cnt = hold_samples; }
else if (cnt > 0) { cnt--; }
else { e *= dec; }
env[i] = e;
}
}
Coefficients hard-coded for hold = 4 samples and decay = 32 samples (the page defaults): dec = exp(-1/32) = 0.9692332. A new peak re-arms the 4-sample hold counter; only after it expires does the envelope decay. No tuning knobs.
#include <math.h>
void peak_hold_fixed(const double *x, double *env, int n) {
const double dec = 0.9692332; /* exp(-1/32) */
const int hold = 4; /* page default hold, samples */
double e = 0.0;
int cnt = 0;
for (int i = 0; i < n; i++) {
double r = fabs(x[i]);
if (r >= e) { e = r; cnt = hold; } /* instant attack, re-arm hold */
else if (cnt > 0) cnt--; /* hold the peak */
else e *= dec; /* then decay */
env[i] = e;
}
}
The two sliders map straight onto the kernel. Hold (0–1500 samp) is how long the line sits flat at a peak before it starts falling — raise it and brief dips between crests are ignored. Decay (1–3000 samp) is the time constant of the fall once the hold expires — larger decay bleeds down more slowly, holding level after a peak.
#include <math.h>
static double decay_coeff(double tau) {
return exp(-1.0 / (tau < 1.0 ? 1.0 : tau));
}
void peak_hold_ctl(const double *x, double *env, int n,
double decay_samples, /* slider: 1 .. 3000 */
int hold_samples) { /* slider: 0 .. 1500 */
double dec = decay_coeff(decay_samples);
if (hold_samples < 0) hold_samples = 0;
double e = 0.0;
int cnt = 0;
for (int i = 0; i < n; i++) {
double r = fabs(x[i]);
if (r >= e) { e = r; cnt = hold_samples; }
else if (cnt > 0) { cnt--; }
else { e *= dec; }
env[i] = e;
}
}
