115 lines
4.8 KiB
Python
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())
|