Real-time
Loudness (LUFS)
loudnessAn 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
- polyphonic
- Reads
- perceptual loudness
- Latency
- ½ window
- Cost
- filter + mean-sq
- Domain
- frequency
Scored qualitatively.
This method outputs a normalized contour (onset strength, per-band or perceptual loudness), not an amplitude in the units of the true envelope — so an amplitude error number would be meaningless. Its strength is the spectral axis: read the gallery below.
How it works
How loud it actually sounds, not how big the samples are. Pass the signal through the ITU-R BS.1770 K-weighting filter — a coarse model of the ear's frequency response — then take a short-time mean-square. This is the loudness measure used across streaming and broadcast; it weights mid and high energy more than the lows, so bass-heavy material doesn't read as louder than it sounds, and the contour tracks perceived loudness through a busy mix.
The window sets momentary versus short-term. (The absolute frequencies here are illustrative, but the filter-then-mean-square detector is the real one.)
Key terms
- K-weighting
- The ITU-R BS.1770 pre-filter — a coarse model of the ear's frequency response. A high-shelf then a high-pass that de-emphasizes low frequencies and lifts the upper-mids, so the signal is weighted the way the ear weighs loudness before any power is measured.
- Mean-square / loudness (LUFS)
- The short-time average power of the K-weighted signal — square each sample, average over a sliding window. Reported in LUFS, the loudness unit used across streaming and broadcast to set program level.
- Momentary vs short-term
- The window length. A short window gives momentary loudness that follows fast level changes; a longer window gives short-term loudness, a steadier read of how loud a passage sits.
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 polyphonic input, working up to the finished loudness contour.
- Step 1The raw mix
Start with the polyphonic input — several voices at once. Raw sample height is not loudness: a heavy bass note can dwarf a bright lead on the meter while sounding quieter to the ear.
- Step 2K-weighting
Run the signal through the K-weighting filter, a high-shelf then a high-pass. It de-emphasizes low end and lifts the upper-mids, reshaping the waveform toward what the ear actually weighs as loud.
- Step 3Short-time mean-square
Take a centered mean-square of the K-weighted signal over a sliding window and square-root it. The result is a smooth perceptual loudness contour that hugs the swell of the mix.
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>
/* One Direct-Form-I biquad: y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2. */
static void biquad(const double *x, double *y, int n,
const double b[3], const double a[3]) {
double x1 = 0, x2 = 0, y1 = 0, y2 = 0;
for (int i = 0; i < n; i++) {
double xn = x[i];
double yn = b[0]*xn + b[1]*x1 + b[2]*x2 - a[1]*y1 - a[2]*y2;
x2 = x1; x1 = xn;
y2 = y1; y1 = yn;
y[i] = yn;
}
}
/* ITU-R BS.1770 K-weighting: a high-shelf then a high-pass. */
static void k_weight(const double *x, double *y, double *tmp, int n) {
/* stage 1: high-shelf */
static const double b1[3] = { 1.53512485958697, -2.69169618940638, 1.19839281085285 };
static const double a1[3] = { 1.0, -1.69065929318241, 0.73248077421585 };
/* stage 2: high-pass */
static const double b2[3] = { 1.0, -2.0, 1.0 };
static const double a2[3] = { 1.0, -1.99004745483398, 0.99007225036621 };
biquad(x, tmp, n, b1, a1);
biquad(tmp, y, n, b2, a2);
}
/* Loudness envelope: K-weight, then a centered sliding mean-square of width W
samples, square-rooted, then peak-normalized to the loudest point. */
void loud_envelope(const double *x, double *env, int n, int W) {
double *kw = malloc(n * sizeof(double));
double *tmp = malloc(n * sizeof(double));
k_weight(x, kw, tmp, n);
int half = W >> 1;
double peak = 0.0;
for (int i = 0; i < n; i++) {
int lo = i - half; if (lo < 0) lo = 0;
int hi = i + half + 1; if (hi > n) hi = n;
double sum = 0.0;
for (int j = lo; j < hi; j++) sum += kw[j] * kw[j];
env[i] = sqrt(sum / (hi - lo));
if (env[i] > peak) peak = env[i];
}
if (peak > 0.0) for (int i = 0; i < n; i++) env[i] /= peak;
free(kw); free(tmp);
}
// One Direct-Form-I biquad: y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2.
function biquad(x, b, a) {
const y = new Float64Array(x.length);
let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
for (let i = 0; i < x.length; i++) {
const xn = x[i];
const yn = b[0]*xn + b[1]*x1 + b[2]*x2 - a[1]*y1 - a[2]*y2;
x2 = x1; x1 = xn;
y2 = y1; y1 = yn;
y[i] = yn;
}
return y;
}
// ITU-R BS.1770 K-weighting: a high-shelf then a high-pass.
function kWeight(sig) {
const b1 = [1.53512485958697, -2.69169618940638, 1.19839281085285];
const a1 = [1.0, -1.69065929318241, 0.73248077421585];
const b2 = [1.0, -2.0, 1.0];
const a2 = [1.0, -1.99004745483398, 0.99007225036621];
return biquad(biquad(sig, b1, a1), b2, a2);
}
// Loudness envelope: K-weight, then a centered sliding mean-square of width W
// samples, square-rooted, then peak-normalized to the loudest point.
function loudEnvelope(x, W) {
const kw = kWeight(x);
const n = kw.length;
const half = W >> 1;
const env = new Float64Array(n);
let peak = 0;
for (let i = 0; i < n; i++) {
const lo = Math.max(0, i - half);
const hi = Math.min(n, i + half + 1);
let sum = 0;
for (let j = lo; j < hi; j++) sum += kw[j] * kw[j];
env[i] = Math.sqrt(sum / (hi - lo));
if (env[i] > peak) peak = env[i];
}
if (peak > 0) for (let i = 0; i < n; i++) env[i] /= peak;
return env;
}
import numpy as np
from scipy.signal import lfilter
def k_weight(sig):
"""ITU-R BS.1770 K-weighting: a high-shelf then a high-pass."""
b1 = [1.53512485958697, -2.69169618940638, 1.19839281085285]
a1 = [1.0, -1.69065929318241, 0.73248077421585]
b2 = [1.0, -2.0, 1.0]
a2 = [1.0, -1.99004745483398, 0.99007225036621]
return lfilter(b2, a2, lfilter(b1, a1, sig))
def loud_envelope(x, W):
"""K-weight, then a centered sliding mean-square of width W samples,
square-rooted, then peak-normalized to the loudest point."""
kw = k_weight(np.asarray(x, dtype=float))
n = len(kw)
half = W >> 1
sq = kw * kw
env = np.empty(n)
for i in range(n):
lo = max(0, i - half)
hi = min(n, i + half + 1)
env[i] = np.sqrt(sq[lo:hi].mean())
peak = env.max()
return env / peak if peak > 0 else env
Two changes, same output. The two K-weighting biquads run in a single fused pass — each sample flows shelf→high-pass through one set of state registers, so the intermediate K-weighted buffer is never written. And the window is a running sum-of-squares: instead of re-summing W terms per sample (the O(n·W) inner loop above), add the squared sample entering the window and subtract the one leaving it, dropping the cost to O(n). A second pass peak-normalizes.
#include <math.h>
void loud_envelope_opt(const double *restrict x, double *restrict env,
int n, int W) {
/* stage 1: high-shelf, stage 2: high-pass */
const double b1[3] = { 1.53512485958697, -2.69169618940638, 1.19839281085285 };
const double a1[3] = { 1.0, -1.69065929318241, 0.73248077421585 };
const double b2[3] = { 1.0, -2.0, 1.0 };
const double a2[3] = { 1.0, -1.99004745483398, 0.99007225036621 };
/* prefix sum of K-weighted energy, fused biquads into one pass */
double s1x1 = 0, s1x2 = 0, s1y1 = 0, s1y2 = 0; /* shelf state */
double s2x1 = 0, s2x2 = 0, s2y1 = 0, s2y2 = 0; /* high-pass state */
double *cs = malloc((n + 1) * sizeof(double));
cs[0] = 0.0;
for (int i = 0; i < n; i++) {
double xn = x[i];
double m = b1[0]*xn + b1[1]*s1x1 + b1[2]*s1x2 - a1[1]*s1y1 - a1[2]*s1y2;
s1x2 = s1x1; s1x1 = xn; s1y2 = s1y1; s1y1 = m;
double k = b2[0]*m + b2[1]*s2x1 + b2[2]*s2x2 - a2[1]*s2y1 - a2[2]*s2y2;
s2x2 = s2x1; s2x1 = m; s2y2 = s2y1; s2y1 = k;
cs[i + 1] = cs[i] + k * k; /* running sum of squares */
}
int half = W >> 1;
double peak = 0.0;
for (int i = 0; i < n; i++) {
int lo = i - half; if (lo < 0) lo = 0;
int hi = i + half + 1; if (hi > n) hi = n;
env[i] = sqrt((cs[hi] - cs[lo]) / (hi - lo)); /* O(1) window */
if (env[i] > peak) peak = env[i];
}
if (peak > 0.0) for (int i = 0; i < n; i++) env[i] /= peak;
free(cs);
}
Window hard-coded to 32 samples (the page default); half = 16. The K-weighting biquad coefficients were already fixed constants, so the only knob left was the window — and it is gone. The smallest kernel for a known measurement.
#include <math.h>
#define W 32
#define HALF 16 /* W >> 1 */
void loud_envelope_fixed(const double *x, double *env, int n) {
const double b1[3] = { 1.53512485958697, -2.69169618940638, 1.19839281085285 };
const double a1[3] = { 1.0, -1.69065929318241, 0.73248077421585 };
const double b2[3] = { 1.0, -2.0, 1.0 };
const double a2[3] = { 1.0, -1.99004745483398, 0.99007225036621 };
double *kw = malloc(n * sizeof(double));
double x1 = 0, x2 = 0, y1 = 0, y2 = 0;
for (int i = 0; i < n; i++) { /* shelf */
double xn = x[i];
double yn = b1[0]*xn + b1[1]*x1 + b1[2]*x2 - a1[1]*y1 - a1[2]*y2;
x2 = x1; x1 = xn; y2 = y1; y1 = yn;
kw[i] = yn;
}
x1 = x2 = y1 = y2 = 0;
for (int i = 0; i < n; i++) { /* high-pass */
double xn = kw[i];
double yn = b2[0]*xn + b2[1]*x1 + b2[2]*x2 - a2[1]*y1 - a2[2]*y2;
x2 = x1; x1 = xn; y2 = y1; y1 = yn;
kw[i] = yn;
}
double peak = 0.0;
for (int i = 0; i < n; i++) {
int lo = i - HALF; if (lo < 0) lo = 0;
int hi = i + HALF + 1; if (hi > n) hi = n;
double sum = 0.0;
for (int j = lo; j < hi; j++) sum += kw[j] * kw[j];
env[i] = sqrt(sum / (hi - lo));
if (env[i] > peak) peak = env[i];
}
if (peak > 0.0) for (int i = 0; i < n; i++) env[i] /= peak;
free(kw);
}
The Window slider (32–400 samples, default 128) is the only knob — the K-weighting coefficients stay fixed. It sets the integration time of the mean-square: a longer window averages more samples, so the loudness curve is smoother and slower to move (the short-term reading); a shorter window tightens it toward a momentary reading that tracks each swell.
#include <math.h>
void loud_envelope_ctl(const double *x, double *env, int n,
int window_samples) { /* slider: 32 .. 400 */
const double b1[3] = { 1.53512485958697, -2.69169618940638, 1.19839281085285 };
const double a1[3] = { 1.0, -1.69065929318241, 0.73248077421585 };
const double b2[3] = { 1.0, -2.0, 1.0 };
const double a2[3] = { 1.0, -1.99004745483398, 0.99007225036621 };
double *kw = malloc(n * sizeof(double));
double x1 = 0, x2 = 0, y1 = 0, y2 = 0;
for (int i = 0; i < n; i++) { /* shelf */
double xn = x[i];
double yn = b1[0]*xn + b1[1]*x1 + b1[2]*x2 - a1[1]*y1 - a1[2]*y2;
x2 = x1; x1 = xn; y2 = y1; y1 = yn;
kw[i] = yn;
}
x1 = x2 = y1 = y2 = 0;
for (int i = 0; i < n; i++) { /* high-pass */
double xn = kw[i];
double yn = b2[0]*xn + b2[1]*x1 + b2[2]*x2 - a2[1]*y1 - a2[2]*y2;
x2 = x1; x1 = xn; y2 = y1; y1 = yn;
kw[i] = yn;
}
int half = window_samples >> 1;
double peak = 0.0;
for (int i = 0; i < n; i++) {
int lo = i - half; if (lo < 0) lo = 0;
int hi = i + half + 1; if (hi > n) hi = n;
double sum = 0.0;
for (int j = lo; j < hi; j++) sum += kw[j] * kw[j];
env[i] = sqrt(sum / (hi - lo));
if (env[i] > peak) peak = env[i];
}
if (peak > 0.0) for (int i = 0; i < n; i++) env[i] /= peak;
free(kw);
}