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>
94 lines
3.5 KiB
Python
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
|