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. On the bench an 8.9 minute 320 kbps MP3 went from 14.3 s to 0.9 s and a six-track batch from 124 s to 8 s. On a tester's own library of 1,036 tracks, a Windows desktop went from about 14 minutes to 20 seconds. True peak at or below 0 dBTP agrees with the previous engine to 0.01 dB. This page is the measurement record: the bench was measured on 2026-09-19 with baken 3.6.0, the field results were added on 2026-09-23.

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 (#114) 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 (containers outside the list, a mislabelled file) is measured by ffmpeg as before, so no file that measured before stops measuring. Since 3.6.1 it is skipped when a second engine cannot change the answer: an unreadable file keeps its own error, and a silent one is reported without a second, 15x slower pass (#135). Since 4.7.0 HE-AAC is not measured at all: symphonia decodes some of it without the SBR high band, which put the true peak 0.7 to 0.8 dB low, and its gain cannot be changed without re-encoding anyway (#268).

On real libraries

The bench above is one file at a time. These are the numbers a tester measured on their own library, and the ones measured again here under the same conditions after that report (#114, #129).

LibraryMachineBuildTime to analyse
1,036 tracks, MP3, the tester's own libraryWindows, Intel Core i9-13900Kbaken 3.5.0, loudnormabout 14 min
samesamebaken 3.6.0, in-process20 s
sameMac Studio, M4 MaxMac app, baken-core 3.4.0, loudnorm9 min
samesameMac app 1.0.2, baken-core 3.7.0, mp3rgain 3.6.01 min 4 s
257 MP3s, 2.7 GB, local SSD, warm cacheMacBook Air, M3Mac app core, mp3rgain 3.6.0 (1.0.2)68.0 s (CPU 515 s)
samesameMac app core, mp3rgain 3.8.1 (1.0.3)35.8 s (CPU 246 s)
samesamebaken 3.7.0 (Homebrew)33.8 s (CPU 245 s)

The first two rows are the ones that matter to a DJ: the same files on the same machine, about 14 minutes before and 20 seconds after. That is 42x, well beyond the 15x the one-file bench predicted. The desktop has 24 cores, and how each engine spreads across that many was not measured separately.

The Mac app rows are a different lesson. 1.0.2 linked the same baken-core as the CLI, but its lockfile still resolved mp3rgain 3.6.0, from before the true peak meter was restructured, so every file cost twice the CPU for identical numbers. The 257-file rows isolate that on one machine: 68.0 s on the old lock, 35.8 s on the new one, 33.8 s for the CLI. 1.0.3 ships the new lock. The tester's 1,036 files have not been timed on 1.0.3 yet.

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.

The meter was not the only thing that could send a file back. Up to 3.6.1 the gain rule did it on its own. A lowering is rounded up to a whole 1.5 dB step so the file stays under the ceiling, which leaves it up to one step below, and anything more than 1.0 dB below came back as a re-encode candidate on the next run. On the tester's library that was 7,700 of 24,000 tracks on the second run, and 80 of 253 (32%) in a small test, which is the arithmetic exactly. 3.7.0 waits for a full step before it touches a file again (#138): the tester's repeat run now proposes nothing, and nothing is re-encoded for gain at all.

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 about 2.5x faster again 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).
  • Once decoding is in-process, profile the true peak meter. In mp3rgain it was 68% of the analysis time, more than twice the whole decode, while BS.1770 loudness adds about 0.06 s per 10 minutes of audio. Four accumulators instead of one per phase, and a doubled history buffer instead of a wrapping ring, made it 2.1x faster with bit-identical output (mp3rgain#334, in 3.8.0).
  • Check which analyzer you actually ship. Two workspaces that share a crate resolve its dependencies separately. The Mac app's lockfile stayed on mp3rgain 3.6.0 after the CLI had moved to 3.8.0, and nothing but a tester's stopwatch noticed the doubled CPU per file.
  • 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, the testing and the field numbers on this page.

Back to Bake'n Deck →