Skip to main content
Lightning Jar - Web Studio Lightning Jar Wordmark

Measuring Loudness in the Browser

A watercolor illustration of a vintage gramophone with butterflies drifting from its horn

Every streaming service turns your mix up or down to match a reference level, so a music tool that exports audio has a choice: normalize the bounce yourself, or let Spotify do it to you. For Stem Shovel we chose to do it ourselves, which meant implementing the loudness standard the services use, ITU-R BS.1770, in the browser. It turned out to need no DSP library at all. The browser carries one half of the standard in its own audio nodes, and the other half is plain arithmetic. This post is the map we wish we had, with the numbers verified by tests rather than copied from a forum.

What the Standard Asks For

LUFS sounds exotic and is not. BS.1770 says: filter the signal so it resembles what ears care about, then average its power. The filter is the K-weighting, two biquads in series: a high shelf that lifts everything above roughly 1.7 kHz by about 4 dB (the head gets in the way of high frequencies, so they read louder), and a high-pass around 38 Hz (the lowest rumble barely registers). After the filter, take the mean square of every 400 ms block, stepping forward 100 ms at a time, and sum the channels. A block's loudness is minus 0.691 plus ten times the log of that mean square.

Then the gating, which is the part that makes the number match perception on real songs. Blocks below an absolute gate of minus 70 LUFS are silence and never count. Of the survivors, compute the mean, and drop every block more than 10 LU below it, so the quiet verse does not drag the number under the chorus that defines the song. The mean of what remains is the integrated loudness. The standard also wants a true peak: the highest sample value after four-times oversampling, because the analog waveform between two samples can swing higher than either.

The Live meter is Four Nodes

While the transport runs, Stem Shovel shows momentary and short-term loudness on the master, and the whole measurement graph is stock Web Audio:

const k1 = ctx.createIIRFilter([shelf.b0, shelf.b1, shelf.b2], [1, shelf.a1, shelf.a2]);
const k2 = ctx.createIIRFilter([hp.b0, hp.b1, hp.b2], [1, hp.a1, hp.a2]);
const split = ctx.createChannelSplitter(2);
master.out.connect(k1).connect(k2).connect(split);

IIRFilterNode carries the K-weighting directly; you hand it the biquad coefficients and the browser runs the filter at audio rate for free. Past the splitter, one AnalyserNode per channel reads time-domain samples each animation frame, and the meter sums the channels' mean squares over the last 400 ms and the last 3 seconds.

The splitter is not decoration. An AnalyserNode downmixes its input to mono, and the standard sums the channels' powers after filtering, which is not the same number. Measure a stereo signal through one analyser and you are quietly measuring a different signal. One analyser per channel, fed from a ChannelSplitterNode, reads the real thing.

The Offline Measurement is Plain Arithmetic

The export path cannot wait for a meter, so the bounce is measured by a pure function: the same two biquads run in direct form I over the decoded channels, the same 400 ms blocks at 100 ms hops via a running sum of squares, the same two gates, about 180 lines total. Pure means testable, and the tests are the part we trust: a 1 kHz sine at minus 20 dBFS must read minus 23.01 LUFS in mono and minus 20 in stereo, at 44.1 kHz and at 48 kHz alike, and the 48 kHz coefficients must match the standard's published table to six decimal places. True peak is the same arithmetic spirit: a 12-tap windowed-sinc interpolator fills in three points between every pair of samples.

Gotchas

The standard publishes filter coefficients only for 48 kHz, and browsers decode at whatever rate the context runs, so the coefficients must be derived per sample rate. The first derivation we tried, a published shelf formula with the standard's own center frequency, gain, and Q, did not land on the table. The derivation inside libebur128 does, and the test that pins the table is what caught the difference. Without that test we would have shipped a plausible, wrong meter.

The gating has an honest edge: a block that straddles the end of a tone is part tone and part silence, passes both gates, and pulls the figure a few tenths of an LU under the tone measured alone. That is the standard working as specified, not a bug, and our tests allow for it instead of tightening the gate to flatter the fixture.

And silence deserves a type, not a number. A bounce where nothing rises above the absolute gate has no integrated loudness; the function returns null rather than negative infinity, and the normalizer leaves such a bounce alone.

From a Number to a Gain

Normalization is the easy part once the measurement is honest. The target is minus 14 LUFS, the common streaming reference, with a true-peak ceiling of minus 1 dBTP. The gain applied is a single linear factor: whichever is smaller, the lift that reaches the target or the lift that reaches the ceiling, the same policy as ffmpeg's loudnorm in linear mode. One multiply, no limiter, and the mix a listener hears on a streaming service is the mix the band approved.

The theme under all of it is the one we keep returning to in our Web Audio field report: the browser is a capable studio, but nothing about audio code is trustworthy until you measure its output. A loudness meter is the purest case, software whose entire job is measurement, and it still needed a pinned coefficient table and a known sine to be worth believing.

headshot of Kevin Peckham
Kevin Peckham
Principal, Lightning Jar

Ask Eljay

Ask about the studio's work, research, packages, or writing. A few starters:

Answers come from this site's own content and link their sources. For anything that matters, email hello@lightningjar.com.