再エンコードなしで動画の音量を自動調整する方法
以前、動画の音量を調整したいとき、私はよく次のような手順を使っていました。
- Adobe Premiere Pro でハードリミッターを追加し、動画を書き出す
- HandBrake で再度エンコードする
この方法でも目的は達成できますが、処理に時間がかかります。また、動画ストリームを再エンコードするため、画質が劣化するリスクもあります。
最近、同じ問題に直面し、AI と相談した結果、より良い方法にたどり着きました。それは Python スクリプトから FFmpeg を呼び出し、音声だけを処理する方法 です。
この方法では、動画ストリームはコピーするだけで再エンコードしません。そのため、画質を保ったまま、処理時間も大幅に短縮できます。
基本的な考え方
*.mp4 や *.mkv のようなファイルは「コンテナ形式」です。コンテナは、複数のストリームをまとめて格納するための箱にすぎません。
一般的に、以下のようなストリームを含みます。
- 動画
- 音声
- 字幕
- メタデータ
これらは互いに独立しているため、FFmpeg を使えば 動画をそのままにして、音声だけを処理する ことが可能です。
この Python スクリプトで行っていること
この Python スクリプト(AI によって生成されたもの)は、FFmpeg を使って次の処理を行います。
- loudnorm フィルタを使い、1 回目の解析パスでラウドネスを分析
- loudnorm が出力する JSON を解析
- 取得した数値を使って、2 回目のパスで正確な正規化を実行
- 動画は再エンコードせず、音量だけが揃った新しいファイルを出力
ラウドネスのターゲット設定
正規化では、主に次の 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