# FFmpeg two-pass voiceover loudness workflow for Mac # Original source, checked 2026-10-09 with FFmpeg 9.0.2 and Python 3. # Requires separately installed ffmpeg with loudnorm; no Python packages. # Export approved narration to a local WAV first. No speech generation here. # Run this downloaded text file directly with Python; it is executable source: # python3 ffmpeg-voiceover-loudness-workflow.txt narration.wav narration-normalized.wav # Optional illustrative settings: --i -16 --tp -1.5 --lra 11 # -16 LUFS, -1.5 dBTP and LRA11 are examples, not platform delivery standards. # First pass measures; second pass processes; a final analysis measures encoded WAV. # Read encoded_wav input_i/input_tp/input_lra and second_pass normalization_type. # These measurements use loudnorm. Meters and versions can disagree; targets are # requests, not compliance guarantees. Check with the delivery workflow's meter. # Output is 48kHz 24-bit PCM WAV; original channel count is preserved. # The first audio stream is selected; other streams and container metadata are # outside this small workflow. This is a one-file recipe, not a batch processor. # Existing output names and non-finite measurements fail closed. Output is staged # and installed exclusively; originals are never rewritten. Keep logs private: # /tmp/voiceover-loudnorm-*/ can contain local filenames and audio metadata. # Read the three logs and measurements.json in the printed fresh log directory. # Do not assume normalization fixes clipping, missing words, noise, or bad edits. # After music, AAC/MP3 encoding, or mono-to-stereo mixing, remeasure final audio. # Full decode check: # ffmpeg -hide_banner -loglevel error -i narration-normalized.wav -f null - # Format/duration check: # ffprobe -v error -select_streams a:0 -show_entries stream=codec_name,sample_rate,channels,duration -of json narration-normalized.wav # Primary references: # https://ffmpeg.org/ffmpeg-filters.html#loudnorm # https://ffmpeg.org/ffmpeg.html # https://ffmpeg.org/ffprobe.html # Original reproducible workflow; FFmpeg is a separate project. #!/usr/bin/env python3 """Measure, normalize, then remeasure one local audio file to a new PCM WAV.""" import argparse import json import math import os from pathlib import Path import re import shutil import subprocess import tempfile parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("input") parser.add_argument("output", help="new .wav path; existing files are refused") parser.add_argument("--i", type=float, default=-16.0, help="integrated LUFS target") parser.add_argument("--tp", type=float, default=-1.5, help="maximum true peak, dBTP") parser.add_argument("--lra", type=float, default=11.0, help="loudness range target, LU") args = parser.parse_args() source = Path(args.input).expanduser().resolve(strict=True) destination = Path(args.output).expanduser().absolute() if not source.is_file(): parser.error("input must be a local regular file") if destination.exists() or destination.is_symlink(): parser.error("output already exists; choose a new filename") if destination.suffix.lower() != ".wav" or not destination.parent.is_dir(): parser.error("output must be a .wav in an existing directory") for value, low, high, name in [(args.i, -70, -5, "I"), (args.tp, -9, 0, "TP"), (args.lra, 1, 50, "LRA")]: if not math.isfinite(value) or not low <= value <= high: parser.error(f"{name} must be finite and between {low} and {high}") if not shutil.which("ffmpeg"): parser.error("ffmpeg is not available in Terminal") logs = Path(tempfile.mkdtemp(prefix="voiceover-loudnorm-", dir="/tmp")) base = ["ffmpeg", "-nostdin", "-hide_banner", "-n"] target = f"loudnorm=I={args.i}:TP={args.tp}:LRA={args.lra}" input_args = ["-i", str(source), "-map", "0:a:0", "-vn", "-sn", "-dn"] def run(label, command): result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) (logs / (label + ".log")).write_text(result.stderr) if result.returncode: raise RuntimeError(f"{label} failed; read {logs / (label + '.log')}") return result.stderr def stats(text): objects = [json.loads(item) for item in re.findall(r"\{[^{}]*\}", text)] for item in reversed(objects): if "input_i" in item: return item raise ValueError("FFmpeg did not return loudnorm JSON") def finite(item, keys): for key in keys: if not math.isfinite(float(item[key])): raise ValueError(f"non-finite {key}; input may be silent or below the loudness gate") try: first = stats(run("first-pass", base + input_args + ["-af", target + ":print_format=json", "-f", "null", "-"])) finite(first, ["input_i", "input_tp", "input_lra", "input_thresh", "target_offset"]) measured = ":".join([ f"measured_I={first['input_i']}", f"measured_TP={first['input_tp']}", f"measured_LRA={first['input_lra']}", f"measured_thresh={first['input_thresh']}", f"offset={first['target_offset']}", "linear=true", "print_format=json" ]) # Stage in the destination directory. os.link installs exclusively and atomically. with tempfile.TemporaryDirectory(prefix=".voiceover-loudnorm-", dir=destination.parent) as staging: candidate = Path(staging) / "candidate.wav" second = stats(run("second-pass", base + input_args + ["-af", target + ":" + measured, "-ar", "48000", "-c:a", "pcm_s24le", str(candidate)])) finite(second, ["output_i", "output_tp", "output_lra", "output_thresh", "target_offset"]) check = stats(run("verify", base + ["-i", str(candidate), "-map", "0:a:0", "-af", target + ":print_format=json", "-f", "null", "-"])) finite(check, ["input_i", "input_tp", "input_lra", "input_thresh"]) # Verification input_* fields measure the encoded WAV, not another proposed output. report = {"first_pass": first, "second_pass": second, "encoded_wav": { key: check[key] for key in ["input_i", "input_tp", "input_lra", "input_thresh"]}} (logs / "measurements.json").write_text(json.dumps(report, indent=2) + "\n") os.link(candidate, destination) # FileExistsError also protects a late collision. print(json.dumps({"output": str(destination), "logs": str(logs), **report}, indent=2)) except (ValueError, KeyError, RuntimeError, OSError) as failure: parser.exit(1, f"Stopped: {failure}\nLogs: {logs}\n")