Files
aetherbound-guild/tools/verify_fg2_recording_audio.py
T

115 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""Verify that an FG-2 MovieWriter delivery carries a healthy audio track."""
from __future__ import annotations
import argparse
import array
import json
import math
import shutil
import subprocess
import sys
from pathlib import Path
def run(command: list[str]) -> bytes:
return subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("movie", type=Path)
parser.add_argument("--output", type=Path)
parser.add_argument("--minimum-seconds", type=float, default=21.0)
parser.add_argument("--maximum-seconds", type=float, default=30.1)
parser.add_argument("--minimum-non-silent-ratio", type=float, default=0.10)
args = parser.parse_args()
errors: list[str] = []
if not args.movie.is_file():
raise FileNotFoundError(args.movie)
ffprobe = shutil.which("ffprobe")
ffmpeg = shutil.which("ffmpeg")
if ffprobe is None or ffmpeg is None:
raise RuntimeError("ffprobe and ffmpeg are required")
probe = json.loads(run([
ffprobe, "-v", "error", "-show_streams", "-show_format", "-of", "json", str(args.movie)
]))
video_streams = [stream for stream in probe.get("streams", []) if stream.get("codec_type") == "video"]
audio_streams = [stream for stream in probe.get("streams", []) if stream.get("codec_type") == "audio"]
duration = float(probe.get("format", {}).get("duration", 0.0))
if len(video_streams) != 1:
errors.append(f"video stream count {len(video_streams)}")
if len(audio_streams) != 1:
errors.append(f"audio stream count {len(audio_streams)}")
if not args.minimum_seconds <= duration <= args.maximum_seconds:
errors.append(f"duration {duration:.6f}")
if audio_streams:
audio = audio_streams[0]
if audio.get("codec_name") != "pcm_s16le":
errors.append(f"audio codec {audio.get('codec_name')}")
if int(audio.get("sample_rate", 0)) != 48000:
errors.append(f"audio sample rate {audio.get('sample_rate')}")
if int(audio.get("channels", 0)) != 2:
errors.append(f"audio channels {audio.get('channels')}")
pcm = run([
ffmpeg, "-v", "error", "-i", str(args.movie), "-map", "0:a:0", "-f", "s16le", "-acodec", "pcm_s16le", "-"
]) if audio_streams else b""
samples = array.array("h")
samples.frombytes(pcm)
if sys.byteorder != "little":
samples.byteswap()
normalized = [sample / 32768.0 for sample in samples]
peak = max((abs(value) for value in normalized), default=0.0)
peak_dbfs = 20.0 * math.log10(max(peak, 1.0e-12))
rms = math.sqrt(sum(value * value for value in normalized) / max(1, len(normalized)))
rms_dbfs = 20.0 * math.log10(max(rms, 1.0e-12))
non_silent_ratio = sum(abs(value) >= 0.0015 for value in normalized) / max(1, len(normalized))
clipped_samples = sum(abs(sample) >= 32760 for sample in samples)
if not -24.0 <= peak_dbfs <= -0.5:
errors.append(f"peak {peak_dbfs:.4f} dBFS")
if not -55.0 <= rms_dbfs <= -6.0:
errors.append(f"rms {rms_dbfs:.4f} dBFS")
if non_silent_ratio < args.minimum_non_silent_ratio:
errors.append(f"non-silent ratio {non_silent_ratio:.6f}")
if clipped_samples:
errors.append(f"clipped samples {clipped_samples}")
report = {
"version": 1,
"kind": "abg-fg2-recording-audio-validation",
"ok": not errors,
"errors": errors,
"movie": str(args.movie),
"duration_seconds": round(duration, 6),
"video_streams": len(video_streams),
"audio_streams": len(audio_streams),
"audio_codec": audio_streams[0].get("codec_name") if audio_streams else None,
"sample_rate": int(audio_streams[0].get("sample_rate", 0)) if audio_streams else 0,
"channels": int(audio_streams[0].get("channels", 0)) if audio_streams else 0,
"sample_count": len(samples),
"peak_dbfs": round(peak_dbfs, 4),
"rms_dbfs": round(rms_dbfs, 4),
"non_silent_ratio": round(non_silent_ratio, 6),
"minimum_non_silent_ratio": args.minimum_non_silent_ratio,
"clipped_samples": clipped_samples,
}
encoded = json.dumps(report, ensure_ascii=True, indent=2, sort_keys=True) + "\n"
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(encoded, encoding="utf-8")
if errors:
for error in errors:
print(f"FG2_RECORDING_AUDIO_ERROR {error}")
return 1
print(
"ABG_FG2_RECORDING_AUDIO_OK "
f"duration={duration:.6f} codec=pcm_s16le rate=48000 channels=2 "
f"peak_dbfs={peak_dbfs:.4f} rms_dbfs={rms_dbfs:.4f} "
f"non_silent={non_silent_ratio:.6f} clipped=0"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())