In-process BS.1770-4 measurement: how baken dropped ffmpeg loudnorm and got 15x faster

Up to 3.5, baken headroom measured loudness and true peak by running ffmpeg's loudnorm filter. 97% of the analysis time went into that filter and 3% into decoding. 3.6 decodes in-process with symphonia and measures with the BS.1770-4 analyzer from mp3rgain. An 8.9 minute 320 kbps MP3 went from 14.3 s to 0.9 s, a six-track batch from 124 s to 8 s, and true peak at or below 0 dBTP agrees with the previous engine to 0.01 dB. This page is the measurement record. Measured 2026-09-19 with baken 3.6.0.

The setup

Headroom brings every track of a DJ library to one true peak ceiling. It needs two numbers per file: integrated loudness for the report and true peak for the gain decision. Until 3.5 it got both from ffmpeg -i file -af loudnorm=print_format=json -f null -, reading input_i and input_tp out of stderr. A user with a large library reported 10 to 20 minutes per 1000 tracks and pointed out that mp3rgain, a pure-Rust tool by the same author, analyses the same files in seconds. The first hypothesis was the decoder. It was wrong.

Where the time went

M3 MacBook Air (4 performance + 4 efficiency cores), Homebrew ffmpeg 9.0.1, mp3rgain 3.8.1, real tracks, one file at a time, wall-clock seconds.

Fileffmpeg decode onlyffmpeg loudnorm (3.5)ffmpeg ebur128=peak=truemp3rgain --rg2 --true-peakin-process (3.6)
MP3 320 kbps, 8.9 min0.4214.32.20.920.93
MP3 256 kbps, 5.7 min0.489.51.40.590.59
MP3 228 kbps VBR, 19.3 min0.8931.34.72.02.0
MP3 128 kbps, 15.3 min0.6024.53.71.51.5
MP3 128 kbps, 74 min mix3.011618.09.97.3
MP3 16 kbps mono 16 kHz, 82 min0.8366.29.51.61.5
WAV 24-bit, 4.6 min0.057.31.0n/a0.34
FLAC, 4.5 min0.077.21.0n/a0.45
AAC-LC 128 kbps, 5.9 min0.199.41.40.580.56
the 6 MP3s as one batch (8 threads)1243.78.1

Decoding is about 3% of the loudnorm figure. The filter itself is the rest, and it costs about 1.6 s per minute of audio whatever the codec: a WAV that decodes in 0.05 s still takes 7.3 s.

loudnorm is a normaliser, not a meter. Its true peak detection is a sample-peak search at 192 kHz, so it asks libavfilter for 192 kHz input and an aresample is inserted in front of it. Every sample, 4.35 times as many as the file has, then runs in double precision through the filter's complete gating, gain computation and limiting path. print_format=json only prints the measurement that fell out of that pass. When all you read is input_i and input_tp, you are paying for a normaliser whose output goes to -f null.

ffmpeg 9.0.1 -loglevel verbose -af loudnorm
[Parsed_loudnorm_0] auto-inserting filter 'auto_aresample_0' between 'graph_-1_in_0:0' and 'Parsed_loudnorm_0'
[auto_aresample_0] ch:2 chl:stereo fmt:fltp r:44100Hz -> ch:2 chl:stereo fmt:dbl r:192000Hz
Output #0, null, to 'pipe:': Stream #0:0: Audio: pcm_s16le, 192000 Hz, stereo, s16, 6144 kb/s

What replaced it

  • Decoding: symphonia (pure Rust, MPL-2.0) probes the container and decodes the default audio track: MP3, AAC-LC in .m4a, .mp4 and ADTS, FLAC, WAV, AIFF and ALAC.
  • Loudness: the BS.1770-4 analyzer in mp3rgain. Each decoded buffer is copied out as interleaved f64 and fed frame by frame: K-weighting (shelf plus RLB high-pass, coefficients derived analytically for the file's sample rate), 400 ms gating blocks at 100 ms steps, absolute gate at -70 LUFS, relative gate 10 LU below the mean of the surviving blocks. Its unit tests pass the EBU Tech 3341 sine cases.
  • True peak: the same crate's Annex 2 meter. A 49-tap Hann-windowed sinc polyphase interpolator, 4x oversampling (2x at 88.2 kHz and above), the same design libebur128 uses. The centre tap sits on one phase, so the meter never reads below the sample peak.
  • About 100 lines in analyzer.rs. symphonia was already linked into the binary through mp3rgain's AAC support, so only the lossless decoders were new: the binary grew by 1.3 MB.
  • The old loudnorm run stays as the fallback. Anything symphonia cannot open (HE-AAC with SBR, containers outside the list, a mislabelled file) is measured by ffmpeg as before, so no file that measured before stops measuring.

Does it measure the same thing?

Same files, old engine (loudnorm) against 3.6. Integrated loudness in LUFS, true peak in dBTP.

Fileloudnorm I / TP3.6 I / TPDecision at the default ceiling
MP3 128 kbps, 74 min-18.61 / -1.46-18.57 / -1.46none, both
MP3 256 kbps, 5.7 min-9.02 / +1.73-8.98 / +1.622 steps down, both
MP3 228 kbps, 19.3 min-8.32 / +3.28-8.29 / +3.373 steps down, both
MP3 320 kbps, 8.9 min-11.25 / +0.85-11.21 / +0.861 step down, both
MP3 128 kbps, 15.3 min-19.68 / -0.41-19.63 / -0.411 step down, both
MP3 16 kbps mono, 82 min-18.48 / -2.75-18.36 / -2.751 step up, both
WAV 24-bit-4.48 / +3.94-4.42 / +3.76exact lowering: -4.44 dB vs -4.26 dB
FLAC-6.24 / +1.21-6.19 / +1.20exact lowering: -1.71 dB vs -1.70 dB
AAC-LC 128 kbps-12.17 / +1.25-12.13 / +1.252 steps down, both
  • Integrated loudness agrees within 0.06 LU (0.12 on the odd 16 kHz mono file).
  • True peak is identical to 0.01 dB for every file at or below 0 dBTP. That is where the raise decision and the 0.05 dB dead zone live, so a library processed by 3.5 is not re-proposed by 3.6.
  • Hard-clipped masters well above the ceiling (+1.5 dBTP and beyond) differ by 0.1 to 0.2 dB.

Both numbers are estimates of how far the reconstructed waveform reaches between samples. loudnorm's estimate is the sample peak after libswresample has resampled to 192 kHz with its default windowed sinc; the Annex 2 meter is a 4x polyphase FIR with a specified passband. On ordinary material the two agree to the last printed digit. Hard clipping is different: it puts energy right up to Nyquist, which is exactly where interpolation filters differ most, and the standard itself allows a 4x meter to under-read such signals by a few tenths of a dB. Neither is "the" true peak; both sit inside the tolerance the standard describes. For baken it does not matter: the files that differ are 1.5 to 4 dB above the ceiling and are lowered in 1.5 dB steps, and the step count was identical on every test file.

If you measure with ffmpeg

  • If all you need is I and true peak, loudnorm is the wrong filter. ebur128=peak=true measures the same I and true peak in a sixth of the time, at the file's native rate. Its summary prints one decimal; ebur128=metadata=1 followed by ametadata=mode=print gives full-precision per-frame values (take the last frame).
  • If you control the binary, a decoder plus a BS.1770 implementation in-process is another 3x faster and removes the stderr parsing. loudnorm's JSON arrives in the same stream as the ID3 dump, and GEOB/PRIV frames containing braces have broken brace matching before (#10).
  • Keep the ffmpeg path as the fallback. Pure-Rust decoders cover the common formats; ffmpeg opens everything.
  • Do not chase the 0.1 dB on clipped masters. Different interpolators give different answers there, and both are inside the standard's tolerance.

Reproduce it

Decode only, loudnorm, ebur128, and the 192 kHz evidence. The last line is baken 3.6.0 or later.

Terminal
$ time ffmpeg -nostdin -i track.mp3 -map 0:a:0 -f null -
$ time ffmpeg -nostdin -i track.mp3 -map 0:a:0 -af loudnorm=print_format=json -f null -
$ time ffmpeg -nostdin -i track.mp3 -map 0:a:0 -af ebur128=peak=true -f null -
$ ffmpeg -nostdin -loglevel verbose -i track.mp3 -af loudnorm -f null - 2>&1 | grep aresample
$ time baken headroom --analyze-only --no-report ~/Music/DJ

The full measurement log and the design are in issue #129, the implementation in PR #130, and the BS.1770 code in mp3rgain's bs1770.rs. Both projects are MIT.

Credits

Decoding by Symphonia, Philip Deljanov and contributors. The true peak meter follows the design of Jan Kokemüller's libebur128. ffmpeg still does every job in baken that writes audio. Thanks to Alex2Code for the report and the testing.

Back to Bake'n Deck