Two-pass EBU R128 loudness normalization
loudnorm can run in a single pass, but a single pass is a guess: it estimates
gain from the first chunk of audio it sees and applies it blind. The correct sequence
measures the whole file first, then re-runs with those exact measured values.
Pass 1 — measure
Run loudnorm in analysis mode against a null output and capture the JSON it
prints at the end. Nothing is written yet.
ffmpeg -hide_banner -i talk.wav -af "loudnorm=I=-14:TP=-1.0:LRA=11:print_format=json" -f null - 2>&1
The JSON block includes input_i, input_tp, input_lra,
input_thresh, and target_offset — the five values pass 2 needs.
Pass 2 — apply the measured values
Feed those five values back into a second loudnorm call with
linear=true, which applies a single linear gain instead of re-estimating.
The video stream (if any) is copied untouched; only audio is re-encoded, resampled to
48kHz.
ffmpeg -hide_banner -y -i talk.wav -c:v copy \
-af "loudnorm=I=-14:TP=-1.0:LRA=11:measured_I=$MI:measured_TP=$MTP:measured_LRA=$MLRA:measured_thresh=$MTH:offset=$OFF:linear=true" \
-ar 48000 talk_norm.wav
$MI, $MTP, $MLRA, $MTH, and
$OFF are the input_i / input_tp / input_lra
/ input_thresh / target_offset fields parsed out of pass 1's JSON.
The toolkit's loudnorm_2pass.sh script parses them with a small
grep/sed helper; see the script for the exact one-liner.
Defaults: -14 LUFS integrated, -1 dBTP, 11 LU range
I=-14 (integrated loudness), TP=-1.0 (true peak ceiling), and
LRA=11 (loudness range) are the toolkit's defaults — broadcast-consistent
numbers that land close to what YouTube and most social platforms already target internally,
so a clip normalized to them doesn't get turned down (or up) hard on playback. They're
parameterized in the script; override them per delivery spec if a platform asks for
something different.
Why single-pass is a guess
Single-pass loudnorm (just -af loudnorm=I=-14:TP=-1.0:LRA=11 with
no measure step) uses a rolling estimate as it streams through the file and can drift,
especially on material with big dynamic swings — a quiet intro followed by a loud section
can throw the running estimate off enough that the export lands noticeably off-target.
The two-pass sequence removes that guess: pass 1 measures the actual whole-file loudness,
pass 2 applies exactly that.
Verify with a re-measure
Don't trust the pass-2 log line — measure the exported file itself, the same way you'd measure any other input:
ffmpeg -i talk_norm.wav -af loudnorm=I=-14:TP=-1.0:LRA=11:print_format=summary -f null -
A normalized file measured this way against a −14 LUFS target should read close to it — the toolkit's own test run measured a normalized sine back at −13.95 LUFS on ffmpeg 8.1. If the re-measure is off by more than a fraction of a LU, something upstream (wrong measured values passed to pass 2, or a lossy intermediate re-encode) is worth checking.
Related
Read the free preview for the SOP Pack's table of contents and one full script-gate check.
The full pack ($19) has the worked example, prompts and QA receipts; the $9 toolkit is just the scripts.