loudnorm-scan.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. #!/usr/bin/env python3
  2. """Two-pass EBU R128 loudness: run the measurement pass, emit the exact pass-2 filter.
  3. One-pass loudnorm runs in dynamic mode (pumps quiet passages). Proper linear
  4. normalization needs the measured values fed back in — this script runs pass 1,
  5. parses loudnorm's JSON report off stderr, and prints the ready-to-paste pass-2
  6. filter string (and full command), so the agent never re-derives the dance.
  7. Usage: loudnorm-scan.py [-I LUFS] [--tp dBTP] [--lra LU] [--json] <file>
  8. Input: one media file with an audio stream
  9. Output: stdout = measured values + pass-2 filter (or --json envelope,
  10. schema claude-mods.ffmpeg-ops.loudnorm/v1)
  11. Stderr: progress, errors
  12. Exit: 0 ok, 2 usage, 3 file not found, 4 no audio / parse failure,
  13. 5 ffmpeg missing
  14. Targets: -14 streaming platforms, -16 podcasts (default), -23 EBU R128 broadcast.
  15. Examples:
  16. loudnorm-scan.py podcast.wav
  17. loudnorm-scan.py -I -14 --json music.mp4 | jq -r '.data.pass2_filter'
  18. loudnorm-scan.py -I -23 --tp -2 --lra 7 broadcast.mov
  19. """
  20. import argparse
  21. import json
  22. import shutil
  23. import subprocess
  24. import sys
  25. from pathlib import Path
  26. from typing import NoReturn
  27. SCHEMA = "claude-mods.ffmpeg-ops.loudnorm/v1"
  28. EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_VALIDATION, EXIT_MISSING_DEP = 0, 2, 3, 4, 5
  29. def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
  30. if json_mode:
  31. print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
  32. print(f"ERROR: {message}", file=sys.stderr)
  33. sys.exit(exit_code)
  34. def main() -> int:
  35. ap = argparse.ArgumentParser(
  36. description="Measure loudness (pass 1) and emit the exact pass-2 loudnorm filter.",
  37. epilog="Examples:\n"
  38. " loudnorm-scan.py podcast.wav\n"
  39. " loudnorm-scan.py -I -14 --json music.mp4 | jq -r '.data.pass2_filter'\n",
  40. formatter_class=argparse.RawDescriptionHelpFormatter)
  41. ap.add_argument("file", help="media file with an audio stream")
  42. ap.add_argument("-I", "--target-i", type=float, default=-16.0,
  43. help="integrated loudness target, LUFS (default -16)")
  44. ap.add_argument("--tp", type=float, default=-1.5,
  45. help="true-peak ceiling, dBTP (default -1.5)")
  46. ap.add_argument("--lra", type=float, default=11.0,
  47. help="loudness range target, LU (default 11)")
  48. ap.add_argument("--json", action="store_true", help="emit JSON envelope on stdout")
  49. args = ap.parse_args()
  50. ffmpeg = shutil.which("ffmpeg")
  51. if not ffmpeg:
  52. err(args.json, "MISSING_DEPENDENCY",
  53. "ffmpeg not found on PATH", EXIT_MISSING_DEP)
  54. path = Path(args.file)
  55. if not path.is_file():
  56. err(args.json, "NOT_FOUND", f"file not found: {path}", EXIT_NOT_FOUND)
  57. base = f"I={args.target_i:g}:TP={args.tp:g}:LRA={args.lra:g}"
  58. print(f"measuring loudness of {path.name} (pass 1)...", file=sys.stderr)
  59. proc = subprocess.run(
  60. [ffmpeg, "-hide_banner", "-nostats", "-i", str(path),
  61. "-af", f"loudnorm={base}:print_format=json", "-f", "null", "-"],
  62. capture_output=True, text=True)
  63. # loudnorm prints its JSON report as the last {...} block on stderr.
  64. stderr = proc.stderr or ""
  65. start, end = stderr.rfind("{"), stderr.rfind("}")
  66. if proc.returncode != 0 or start == -1 or end <= start:
  67. detail = stderr.strip().splitlines()[-1] if stderr.strip() else "no detail"
  68. err(args.json, "VALIDATION",
  69. f"loudnorm measurement failed (no audio stream?): {detail}",
  70. EXIT_VALIDATION)
  71. try:
  72. m = json.loads(stderr[start:end + 1])
  73. except json.JSONDecodeError:
  74. err(args.json, "VALIDATION", "could not parse loudnorm JSON report",
  75. EXIT_VALIDATION)
  76. pass2_filter = (
  77. f"loudnorm={base}"
  78. f":measured_I={m['input_i']}:measured_TP={m['input_tp']}"
  79. f":measured_LRA={m['input_lra']}:measured_thresh={m['input_thresh']}"
  80. f":offset={m['target_offset']}:linear=true"
  81. )
  82. # loudnorm internally resamples to 192 kHz — the -ar 48000 puts it back.
  83. pass2_command = (f'ffmpeg -y -i "{path}" -af "{pass2_filter}" -ar 48000 '
  84. f'-c:v copy "{path.stem}.normalized{path.suffix}"')
  85. data = {
  86. "file": str(path),
  87. "target": {"I": args.target_i, "TP": args.tp, "LRA": args.lra},
  88. "measured": {
  89. "input_i": float(m["input_i"]),
  90. "input_tp": float(m["input_tp"]),
  91. "input_lra": float(m["input_lra"]),
  92. "input_thresh": float(m["input_thresh"]),
  93. "target_offset": float(m["target_offset"]),
  94. },
  95. "normalization_mode": m.get("normalization_type", ""),
  96. "pass2_filter": pass2_filter,
  97. "pass2_command": pass2_command,
  98. }
  99. if args.json:
  100. print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
  101. else:
  102. print(f"measured I={m['input_i']} LUFS TP={m['input_tp']} dBTP "
  103. f"LRA={m['input_lra']} LU thresh={m['input_thresh']}")
  104. print(f"target I={args.target_i:g} TP={args.tp:g} LRA={args.lra:g}")
  105. print(f"pass2 {pass2_filter}")
  106. print(f"command {pass2_command}")
  107. return EXIT_OK
  108. if __name__ == "__main__":
  109. sys.exit(main())