Smart volume leveling for video files, without re-encoding the video

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:

  1. Run a first pass with loudnorm to analyze loudness
  2. Parse the JSON output from loudnorm
  3. Run a second pass using those measured values to apply corrected normalization
  4. 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.mp4

Make 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.