Short answer: run FFmpeg's loudnorm filter twice. The first pass measures the file and prints JSON; the second pass feeds those numbers back with linear=true so the whole file gets one clean gain change. Then read the JSON from the second pass, because linear=true is a request, not a guarantee. On six real text-to-speech takes I tested, loudnorm silently switched to dynamic mode every time.
This post shows the two-pass script, a strict-linear alternative, and the pytest checks that caught the difference.

Six real TTS takes measured with loudnorm: grey original, blue two-pass loudnorm (dynamic), orange strict linear gain capped at -1.5 dBTP.
The test material
I used six real voice takes that are published as examples on the ElevenLabs v4 voice workspace on Voor: English, Spanish and Japanese narration, some on v4 and some on v4 Turbo, all 44.1 kHz mono MP3 between 9.4 and 17.9 seconds. They are a good stress test because TTS output arrives at whatever loudness the model happened to produce. Measured with loudnorm, they ranged from -19.9 to -26.2 LUFS integrated, a 6.3 LU spread. Put those side by side in a playlist or a video and listeners reach for the volume knob.
Targets used throughout: -16 LUFS integrated, -1.5 dBTP true peak, LRA 11, a common choice for podcasts and web video. Change them for broadcast (-23 LUFS) or a platform spec.
Pass 1 and pass 2 in Python
def measure(path: str | Path, i=TARGET_I, tp=TARGET_TP, lra=TARGET_LRA) -> dict:
"""Pass 1: analyse only, write nothing."""
log = _run(["ffmpeg", "-hide_banner", "-nostats", "-i", str(path),
"-af", f"loudnorm=I={i}:TP={tp}:LRA={lra}:print_format=json",
"-f", "null", "-"])
match = _JSON.search(log)
if not match:
raise RuntimeError("loudnorm printed no JSON")
raw = json.loads(match.group(0))
return {k: float(v) if k != "normalization_type" else v for k, v in raw.items()}
def normalize(src: str | Path, dst: str | Path, i=TARGET_I, tp=TARGET_TP, lra=TARGET_LRA) -> dict:
"""Pass 2: feed the pass-1 numbers back so loudnorm can stay linear."""
m = measure(src, i, tp, lra)
if not math.isfinite(m["input_i"]) or m["input_i"] <= -70.0:
raise ValueError(f"{src}: no measurable programme loudness (silent input?)")
info = probe(src)
dst = Path(dst)
codec = (["-c:a", "libmp3lame", "-b:a", "192k"] if dst.suffix == ".mp3"
else ["-c:a", "pcm_s16le"])
af = (f"loudnorm=I={i}:TP={tp}:LRA={lra}"
f":measured_I={m['input_i']}:measured_TP={m['input_tp']}"
f":measured_LRA={m['input_lra']}:measured_thresh={m['input_thresh']}"
f":offset={m['target_offset']}:linear=true:print_format=json")
log = _run(["ffmpeg", "-hide_banner", "-nostats", "-y", "-i", str(src), "-af", af,
# loudnorm resamples to 192 kHz internally; put the original rate back
"-ar", str(info["sample_rate"]), *codec, str(dst)])
second = json.loads(_JSON.search(log).group(0))
return {"before": m, "mode": second["normalization_type"], "after": measure(dst, i, tp, lra)}
def normalize_linear(src: str | Path, dst: str | Path, i=TARGET_I, tp=TARGET_TP) -> dict:
"""Strict linear gain: never compress, stop at the true-peak ceiling and report the shortfall."""
m = measure(src, i, tp)
if not math.isfinite(m["input_i"]) or m["input_i"] <= -70.0:
raise ValueError(f"{src}: no measurable programme loudness (silent input?)")
wanted = i - m["input_i"]
gain = min(wanted, tp - m["input_tp"])
info = probe(src)
dst = Path(dst)
codec = (["-c:a", "libmp3lame", "-b:a", "192k"] if dst.suffix == ".mp3"
else ["-c:a", "pcm_s16le"])
_run(["ffmpeg", "-hide_banner", "-nostats", "-y", "-i", str(src),
"-af", f"volume={gain:.2f}dB", "-ar", str(info["sample_rate"]), *codec, str(dst)])
return {"before": m, "mode": "linear", "gain_db": round(gain, 2),
"shortfall_lu": round(wanted - gain, 2), "after": measure(dst, i, tp)}
Three details matter more than the filter string:
-
Silence check. A silent file measures as
-inf(or -70 LUFS). Normalizing it would apply absurd gain, sonormalize()raisesValueErrorinstead. -
Sample rate. loudnorm upsamples to 192 kHz internally. Without
-aryou silently ship a 192 kHz file. The script puts the source rate back. -
normalization_type. The second pass prints JSON too. Itsnormalization_typesays whether you actually gotlinearordynamic.
What happened on the real takes
take before I TP LRA -> mode after I TP LRA
dental-sarah-v4 -19.93 -1.72 1.70 -> dynamic -16.23 -1.50 1.60
harbor-george-v4-turbo -22.58 -3.61 12.40 -> dynamic -16.60 -1.48 5.70
harbor-george-v4 -22.15 -1.72 9.50 -> dynamic -16.60 -1.48 4.10
market-laura-es-v4 -22.57 -2.72 1.00 -> dynamic -16.46 -1.48 1.40
pass-brian-v4-turbo -21.55 -3.57 1.50 -> dynamic -16.07 -1.50 1.70
rain-lily-ja-v4 -26.16 -7.88 7.40 -> dynamic -17.29 -1.08 4.30
Every file fell back to dynamic mode. The reason is arithmetic: to raise dental-sarah-v4 from -19.9 to -16 LUFS you need +3.7 dB of gain, but its true peak is already -1.72 dBTP, so a single linear gain would push the peak to about +2 dBTP. loudnorm cannot honour both the loudness target and the peak ceiling linearly, so it compresses instead. The harbor turbo take also has an LRA of 12.4, above the LRA target of 11, which forces dynamic mode on its own.
Dynamic mode did its job on loudness: five of six landed within 0.6 LU of -16, and the spread between takes dropped from 6.3 LU to 1.2 LU. But look at the loudness range column. The whispered-to-excited harbor take went from 12.4 LU to 5.7 LU. That is the performance being flattened.

Same take three ways. Dynamic mode lifts the quiet whisper section toward the loud section; linear gain keeps the original shape.
The shortest take, rain-lily-ja-v4 (9.4 s), also undershot (-17.29 LUFS) and slightly overshot the peak target (-1.08 dBTP). Short clips give loudnorm's dynamic mode little to work with, so test your shortest files, not just your longest.
A strict linear alternative
If the expressive range matters more than hitting -16 exactly, use plain gain capped at the true-peak ceiling and report the shortfall:
def normalize_linear(src, dst, i=TARGET_I, tp=TARGET_TP):
m = measure(src, i, tp)
if not math.isfinite(m["input_i"]) or m["input_i"] <= -70.0:
raise ValueError(f"{src}: no measurable programme loudness (silent input?)")
wanted = i - m["input_i"]
gain = min(wanted, tp - m["input_tp"]) # never clip past the ceiling
rate = probe(src)["sample_rate"]
_run(["ffmpeg", "-y", "-i", str(src), "-af", f"volume={gain:.2f}dB",
"-ar", str(rate), "-c:a", "pcm_s16le", str(dst)])
return {"gain_db": round(gain, 2), "shortfall_lu": round(wanted - gain, 2),
"before": m, "after": measure(dst, i, tp)}
On the same six files this kept every LRA unchanged and every peak at -1.50 dBTP, but loudness only reached -19.5 to -21.9 LUFS, short of the target by 3.5 to 5.9 LU. That is the honest trade-off: hit the target with compression, or keep the dynamics and accept a quieter file. A third option is a deliberate limiter stage before linear gain, which at least makes the compression a choice you can hear and tune.
Tests that keep the pipeline honest
@pytest.mark.parametrize("src", FIXTURES, ids=lambda p: p.stem)
def test_reports_when_linear_was_impossible(normalized, src):
_, _, r = normalized[src.name]
b = r["before"]
linear_possible = (TARGET_I - b["input_i"]) <= (TARGET_TP - b["input_tp"]) and b["input_lra"] <= 11
if not linear_possible:
assert r["mode"] == "dynamic"
@pytest.mark.parametrize("src", FIXTURES, ids=lambda p: p.stem)
def test_keeps_rate_channels_and_length(normalized, src):
s, dst, _ = normalized[src.name]
a, b = probe(s), probe(dst)
assert b["sample_rate"] == a["sample_rate"] # not loudnorm's 192 kHz
assert b["channels"] == a["channels"]
assert abs(b["duration"] - a["duration"]) < 0.08 # mp3 priming/padding only
@pytest.mark.parametrize("src", FIXTURES, ids=lambda p: p.stem)
def test_strict_linear_keeps_dynamics_and_peak(src, tmp_path):
r = normalize_linear(src, tmp_path / "lin.wav")
assert r["after"]["input_tp"] <= TARGET_TP + 0.3
assert abs(r["after"]["input_lra"] - r["before"]["input_lra"]) <= 0.6
assert r["shortfall_lu"] >= 0
The full suite also checks the integrated target (within 1.5 LU in dynamic mode), the peak ceiling, the shrinking spread between takes, near-idempotence on a second run and the silence guard. Run with uvx --with pytest pytest -v: 35 passed in 6.98 s on FFmpeg from Homebrew.
Checklist before you ship normalized audio
- Log
normalization_typefrom pass 2 and fail or warn when you required linear. - Restore the sample rate with
-ar; check it withffprobe. - Guard against silent or near-silent inputs.
- Compare LRA before and after for speech with intentional whispers or shouts.
- Test the shortest clip in the batch.
- Re-measure the output; never trust the target you asked for.
Generating your own test takes
If you want fresh fixtures with deliberate quiet and loud passages, the ElevenLabs v4 text-to-speech generator on Voor takes a tagged script such as [whispering] ... [excited] ...; its default tagged-narration script quotes 6 credits, and generating requires signing in. The takes above came from that page's published examples, so you can reproduce every number here without generating anything.
Top comments (0)