再エンコードなしで動画の音量を自動調整する方法

以前、動画の音量を調整したいとき、私はよく次のような手順を使っていました。

  • Adobe Premiere Pro でハードリミッターを追加し、動画を書き出す
  • HandBrake で再度エンコードする

この方法でも目的は達成できますが、処理に時間がかかります。また、動画ストリームを再エンコードするため、画質が劣化するリスクもあります。

最近、同じ問題に直面し、AI と相談した結果、より良い方法にたどり着きました。それは Python スクリプトから FFmpeg を呼び出し、音声だけを処理する方法 です。

この方法では、動画ストリームはコピーするだけで再エンコードしません。そのため、画質を保ったまま、処理時間も大幅に短縮できます。

基本的な考え方

*.mp4*.mkv のようなファイルは「コンテナ形式」です。コンテナは、複数のストリームをまとめて格納するための箱にすぎません。

一般的に、以下のようなストリームを含みます。

  • 動画
  • 音声
  • 字幕
  • メタデータ

これらは互いに独立しているため、FFmpeg を使えば 動画をそのままにして、音声だけを処理する ことが可能です。

この Python スクリプトで行っていること

この Python スクリプト(AI によって生成されたもの)は、FFmpeg を使って次の処理を行います。

  1. loudnorm フィルタを使い、1 回目の解析パスでラウドネスを分析
  2. loudnorm が出力する JSON を解析
  3. 取得した数値を使って、2 回目のパスで正確な正規化を実行
  4. 動画は再エンコードせず、音量だけが揃った新しいファイルを出力

ラウドネスのターゲット設定

正規化では、主に次の 3 つのパラメータを使用します。
AI の提案に基づき、以下の値を採用しました。

  • 統合ラウドネス(Integrated Loudness):-16 LUFS
    (オンライン動画でよく使われる値)
  • トゥルーピーク(True Peak):-1.5 dBTP
    (クリッピングを防ぐため)
  • ラウドネスレンジ(LRA):11 LU
    (静かな部分と大きな部分の差を抑える)

これらのパラメータの意味について詳しく知りたい場合は、
「loudnorm における LUFS、dBTP、LRA とは何か、それぞれを変更すると聴感にどう影響するか」
といった内容を AI に質問するとよいでしょう。

用途に応じて、これらの値は自由に調整できます。

準備するもの

必要なものは次の 2 つだけです。

  • FFmpeg
  • Python

インストール方法が分からない場合は、
「Windows 11 に FFmpeg をインストールする方法」
のように AI に直接聞くのが簡単です。

コード

以下のコードを、例えば 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()

実行方法

ターミナルまたはコマンドプロンプトで、次のコマンドを実行します。

python loudnorm_2pass.py input.mp4 output.mp4

※ Python スクリプトのパス、および入力/出力する動画ファイルのパスは、実際の環境に合わせて正しいものに置き換えてください。

Windows の例

python "D:\loudnorm_2pass.py" "D:\Cuts\TownScaper.mp4" "D:\Cuts\TownScaper_2.mp4"

output.mp4 に指定したパスが、新しく生成される動画ファイルです。同じ場所に同名のファイルがすでに存在する場合は、上書きされます。

複数の音声トラックがある場合

動画に複数の音声トラックが含まれている場合は、--audio-index オプションを使って、処理する音声トラックを指定できます。

例:

python loudnorm_2pass.py input.mp4 output.mp4 --audio-index 1