festifun/backend/tests/test_ingest.py
m3ultra 157c783e25 Lane A (M1): ingest + GCC-PHAT audio sync
Fills the foundation stubs in ingest.py and audio_sync.py.

- ingest: ffprobe probe + mono 16 kHz WAV extraction per video (ffmpeg);
  idempotent run_ingest reuses existing rows so synthetic -> ingest -> sync
  works without unique-constraint collisions.
- sync: PHAT-whitened GCC-PHAT pairwise offsets with sub-sample parabolic
  peak refinement, scored by peak-to-second-peak ratio; confidence-weighted
  global least-squares solve with cycle-consistency rejection (>50 ms);
  windowed drift estimation (ppm) with a 5 ppm deadband; disconnected
  sync-graph components -> offset_ms=None (spec pitfall #5).
- Persists offset_ms/drift_ppm/sync_confidence via db.update_video_sync and
  exports data/work/sync.json.

Verified on the synthetic fixture: synthetic -> ingest -> sync recovers the
ground-truth offsets (0 / +1370 / -842 ms) to <0.001 ms and drift 0 ppm,
well inside the spec M1 tolerances (+/-10 ms, +/-3 ppm). pytest: 43 passed
(24 foundation + 19 new in test_ingest.py and test_audio_sync.py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:13:56 +10:00

94 lines
3.5 KiB
Python

"""Ingest tests (spec M1, lane A).
Pure unit tests for the path/fps helpers, plus ffmpeg-gated tests that probe a real
rendered clip, extract audio, and run the full idempotent ``run_ingest``.
"""
from __future__ import annotations
import shutil
import numpy as np
import pytest
from festival4d import config, db, ingest, synthetic
# ---------------------------------------------------------------------------
# pure helpers
# ---------------------------------------------------------------------------
def test_audio_wav_path_convention():
assert ingest.audio_wav_path(7) == config.AUDIO_DIR / "7.wav"
def test_parse_fps():
assert ingest._parse_fps("30/1") == 30.0
assert abs(ingest._parse_fps("30000/1001") - 29.97) < 0.01
assert ingest._parse_fps("25") == 25.0
assert ingest._parse_fps(None) is None
assert ingest._parse_fps("0/0") is None
assert ingest._parse_fps("N/A") is None
def test_run_ingest_no_files_is_empty(tmp_path, monkeypatch):
"""An empty raw dir yields an empty summary, not a crash."""
monkeypatch.setattr(config, "RAW_DIR", tmp_path / "empty_raw")
(tmp_path / "empty_raw").mkdir()
db.init_engine()
db.reset_db()
assert ingest.run_ingest() == []
# ---------------------------------------------------------------------------
# ffmpeg-gated
# ---------------------------------------------------------------------------
def _render_clip(tmp_path, duration_s=2.0, cam_index=0):
sr = config.AUDIO_SAMPLE_RATE
master, _ = synthetic.synth_master_audio(sr)
clip = synthetic.slice_for_offset(master, sr, 0.0, duration_s)
wav = tmp_path / "src.wav"
synthetic.write_wav(wav, clip, sr)
mp4 = tmp_path / f"cam{cam_index}.mp4"
synthetic.render_video(mp4, wav, cam_index, 640, 360, 30, duration_s)
return mp4
@pytest.mark.skipif(shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
reason="ffmpeg/ffprobe required")
def test_probe_video(tmp_path):
mp4 = _render_clip(tmp_path, duration_s=2.0)
info = ingest.probe_video(mp4)
assert info["width"] == 640 and info["height"] == 360
assert abs(info["fps"] - 30.0) < 1.0
assert 1.5 < info["duration_s"] < 2.6
@pytest.mark.skipif(shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
reason="ffmpeg/ffprobe required")
def test_extract_audio(tmp_path):
mp4 = _render_clip(tmp_path, duration_s=2.0)
out = ingest.extract_audio(mp4, tmp_path / "out.wav", sample_rate=16_000)
assert out.exists()
samples, sr = synthetic.read_wav(out)
assert sr == 16_000
assert samples.ndim == 1 # mono
assert abs(len(samples) / sr - 2.0) < 0.3 # ~2 s
@pytest.mark.skipif(shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
reason="ffmpeg/ffprobe required")
def test_run_ingest_registers_and_is_idempotent():
synthetic.build(duration_s=2.0, run_ffmpeg=True) # into the temp data dir (conftest)
summary = ingest.run_ingest()
assert len(summary) == 3
for s in summary:
assert ingest.audio_wav_path(s["video_id"]).exists()
samples, sr = synthetic.read_wav(ingest.audio_wav_path(s["video_id"]))
assert sr == 16_000 and samples.ndim == 1
# Second run reuses the existing rows (no unique-constraint crash) and re-extracts.
again = ingest.run_ingest()
assert len(again) == 3
assert all(s["reused_db_row"] for s in again)
assert len(db.get_videos()) == 3 # no duplicate rows