In the past, when I needed to fix a video’s volume, I would often do this:
- Add a Hard Limiter effect in Adobe Premiere Pro
- Export the video
- Re-encode again in HandBrake
This works, but it is slow. It also risks losing video quality because you are re-encoding the video stream.
Recently, I hit the same problem again and discussed it with an AI. We came up with a better approach: use a Python script to call FFmpeg, so that only the audio is processed. The video stream is copied as-is (no re-encode), so quality stays intact and the processing time is much lower.
Core idea
Files like *.mp4 and *.mkv are containers. A container is just a wrapper that can hold multiple streams:
- video
- audio
- subtitles
- metadata
Because the streams are separate, FFmpeg can process the audio stream while leaving the video stream untouched.
This Python script (generated with AI) calls FFmpeg to:
- Run a first pass with loudnorm to analyze loudness
- Parse the JSON output from loudnorm
- Run a second pass using those measured values to apply corrected normalization
- Output a new file where video is not re-encoded, loudness is consistent
Loudness target settings
The normalization uses three key parameters. Based on the AI suggestion:
- Integrated loudness: -16 LUFS (common for online video)
- True peak ceiling: -1.5 dBTP (helps avoid clipping)
- Loudness range constraint: 11 LU (limits the gap between quiet and loud parts)
If you want a deeper explanation of these parameters, you can ask an AI something like: “Explain LUFS, dBTP, and LRA in loudnorm, and how changing them affects the listening experience.” You can also adjust these values based on your own use case.
Preparation
You need:
- FFmpeg
- Python
If you are unsure how to install them, you can ask an AI directly, for example: “How do I install FFmpeg on Windows 11?”
Main code
Save the following code as a .py file, for example loudnorm_2pass.py.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
def run_capture(cmd: List[str]) -> subprocess.CompletedProcess:
"""
Run a command and capture stdout/stderr (used in pass 1 to capture JSON).
Note: -nostdin is explicitly added in the command to prevent ffmpeg
from waiting for interactive input.
"""
print("\n>>", " ".join(cmd))
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
def run_passthrough(cmd: List[str]) -> int:
"""
Run a command and pass all output directly to the terminal.
Used in pass 2 so the user can see progress and error messages.
"""
print("\n>>", " ".join(cmd))
p = subprocess.run(cmd)
return p.returncode
def must_exist_executable(name: str) -> None:
if shutil.which(name) is None:
raise SystemExit(f"{name} not found. Please install it and ensure it is in PATH.")
def resolve_path(p: str) -> str:
return str(Path(p).expanduser().resolve())
def ffprobe_audio_streams(input_path: str) -> Tuple[int, Dict[str, Any]]:
"""
Return (number_of_audio_streams, raw ffprobe JSON output).
"""
must_exist_executable("ffprobe")
cmd = [
"ffprobe",
"-hide_banner",
"-v", "error",
"-print_format", "json",
"-show_streams",
"-select_streams", "a",
input_path,
]
r = run_capture(cmd)
if r.returncode != 0:
raise SystemExit(f"ffprobe failed:\n{r.stderr}")
try:
data = json.loads(r.stdout)
except json.JSONDecodeError:
raise SystemExit("Failed to parse ffprobe output (not valid JSON).")
streams = data.get("streams", []) or []
return len(streams), data
def find_last_json_object(text: str) -> Dict[str, Any]:
"""
Extract the loudnorm JSON block from ffmpeg stderr.
Strategy: take the substring from the last '{' to the last '}' and
verify that required loudnorm fields are present.
"""
start = text.rfind("{")
end = text.rfind("}")
if start == -1 or end == -1 or end <= start:
raise RuntimeError(
"No loudnorm JSON found in FFmpeg output. "
"Make sure print_format=json is enabled in pass 1 "
"and the input contains an audio stream."
)
blob = text[start:end + 1].strip()
try:
obj = json.loads(blob)
except json.JSONDecodeError as e:
raise RuntimeError(f"Failed to parse loudnorm JSON: {e}\nJSON snippet:\n{blob}")
# Validate required loudnorm fields
for k in ("input_i", "input_tp", "input_lra", "input_thresh"):
if k not in obj:
raise RuntimeError(
f"The JSON found does not look like loudnorm output. "
f"Missing field: {k}\nJSON snippet:\n{blob}"
)
return obj
raise RuntimeError(
"No loudnorm JSON found in FFmpeg output. "
"Make sure print_format=json is enabled in pass 1 "
"and the input contains an audio stream."
)
def main() -> None:
parser = argparse.ArgumentParser(
description="Two-pass EBU R128 loudnorm: copy video, normalize audio; robust non-interactive workflow."
)
parser.add_argument("input", help="Input video file (e.g., input.mp4)")
parser.add_argument("output", help="Output video file (e.g., output.mp4)")
parser.add_argument("--I", default="-16", help="Target integrated loudness (LUFS), default -16")
parser.add_argument("--TP", default="-1.5", help="Target true peak (dBTP), default -1.5")
parser.add_argument("--LRA", default="11", help="Target loudness range, default 11")
parser.add_argument("--audio-index", type=int, default=0,
help="Audio stream index to process, default 0 (i.e. 0:a:0)")
parser.add_argument("--audio-codec", default="aac", help="Output audio codec, default aac")
parser.add_argument("--audio-bitrate", default="192k", help="Output audio bitrate, default 192k")
parser.add_argument("--keep-subtitles", action="store_true",
help="Copy subtitle streams if present (0:s?)")
parser.add_argument("--no-overwrite", action="store_true",
help="Do not overwrite output if it exists (default: overwrite with -y)")
parser.add_argument("--max-muxing-queue-size", type=int, default=1024,
help="Set FFmpeg -max_muxing_queue_size, default 1024")
args = parser.parse_args()
must_exist_executable("ffmpeg")
must_exist_executable("ffprobe")
inp = resolve_path(args.input)
out = resolve_path(args.output)
# 0) Pre-check: ensure audio streams exist
audio_count, _ = ffprobe_audio_streams(inp)
if audio_count == 0:
raise SystemExit("No audio streams detected in input file. Cannot apply loudnorm.")
if args.audio_index < 0 or args.audio_index >= audio_count:
raise SystemExit(
f"--audio-index={args.audio_index} is out of range. "
f"This file has {audio_count} audio streams (valid range: 0..{audio_count-1})."
)
if audio_count > 1:
print(
f"Note: {audio_count} audio streams detected. "
f"This script processes only 0:a:{args.audio_index} "
"(use --audio-index to select a different one)."
)
# Output overwrite policy
if Path(out).exists() and args.no_overwrite:
raise SystemExit(f"Output file already exists and --no-overwrite is set: {out}")
# 1) Pass 1: analysis
analysis_filter = f"loudnorm=I={args.I}:TP={args.TP}:LRA={args.LRA}:print_format=json"
cmd1 = [
"ffmpeg",
"-hide_banner",
"-nostdin", # Critical: disable interactive input to avoid apparent hangs
"-i", inp,
"-map", f"0:a:{args.audio_index}",
"-filter:a", analysis_filter,
"-f", "null", "-"
]
r1 = run_capture(cmd1)
if r1.returncode != 0:
print(r1.stderr, file=sys.stderr)
raise SystemExit("Pass 1 analysis failed.")
info = find_last_json_object(r1.stderr)
# Required measured fields
measured_I = info.get("input_i")
measured_TP = info.get("input_tp")
measured_LRA = info.get("input_lra")
measured_thresh = info.get("input_thresh")
print("\n=== Pass 1 measurement ===")
print(json.dumps(info, ensure_ascii=False, indent=2))
# 2) Pass 2: normalization
norm_filter = (
f"loudnorm=I={args.I}:TP={args.TP}:LRA={args.LRA}:"
f"measured_I={measured_I}:measured_TP={measured_TP}:"
f"measured_LRA={measured_LRA}:measured_thresh={measured_thresh}"
)
cmd2 = [
"ffmpeg",
"-hide_banner",
"-nostdin",
"-y" if not args.no_overwrite else "-n",
"-i", inp,
"-map", "0:v",
"-map", f"0:a:{args.audio_index}",
"-c:v", "copy",
"-filter:a", norm_filter,
"-c:a", args.audio_codec,
"-b:a", args.audio_bitrate,
"-max_muxing_queue_size", str(args.max_muxing_queue_size),
"-movflags", "+faststart"
]
if args.keep_subtitles:
cmd2 += ["-map", "0:s?"] # Copy subtitle streams if present
cmd2 += [out]
# Pass 2: run with passthrough output so progress/errors are visible
rc2 = run_passthrough(cmd2)
if rc2 != 0:
raise SystemExit("Pass 2 processing failed.")
print("\nDone. Output file:", out)
if __name__ == "__main__":
main()How to run
Run the following command in a terminal or command prompt:
python loudnorm_2pass.py input.mp4 output.mp4Make sure to replace the Python script path and the input/output video paths with the correct ones. For example, on Windows:
python "D:\loudnorm_2pass.py" "D:\Cuts\TownScaper.mp4" "D:\Cuts\TownScaper_2.mp4"The output.mp4 path specifies the newly generated video file. If a file already exists at that location, it will be overwritten.
If the video contains multiple audio tracks, you can specify which one to process using the –audio-index option.