festifun/backend/festival4d/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

139 lines
5.0 KiB
Python

"""Video ingest: probe files in ``data/raw`` and extract audio (spec M1, lane A).
For each file in ``config.RAW_DIR``: ffprobe it, insert/update the ``videos`` row via
``db`` helpers, and extract a mono 16 kHz WAV into ``config.AUDIO_DIR`` (one per video,
keyed by ``video_id``) — that WAV is exactly what ``audio_sync.run_sync`` loads.
``run_ingest`` is idempotent: re-running after the synthetic fixture (which already
registered the videos) reuses the existing rows and just re-extracts audio, so the
demo path ``synthetic -> ingest -> sync`` works without unique-constraint collisions.
"""
from __future__ import annotations
import json
import logging
import shutil
import subprocess
from pathlib import Path
from festival4d import config, db
log = logging.getLogger("festival4d.ingest")
# Container extensions we treat as ingestible video (audio is pulled from any of them).
_VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".mkv", ".avi", ".webm"}
def _require(tool: str) -> None:
if shutil.which(tool) is None:
raise RuntimeError(
f"{tool} not found on PATH — required for ingest (macOS: `brew install ffmpeg`)"
)
def audio_wav_path(video_id: int) -> Path:
"""Canonical path of a video's extracted mono WAV.
Shared convention between ingest (writer) and :mod:`festival4d.audio_sync` (reader),
so neither side has to guess filenames.
"""
return config.AUDIO_DIR / f"{video_id}.wav"
def _parse_fps(rate: str | None) -> float | None:
"""Parse an ffprobe frame-rate string (``"30/1"`` or ``"29.97"``) to fps."""
if not rate or rate in ("0/0", "N/A"):
return None
if "/" in rate:
num, den = rate.split("/", 1)
den = float(den)
return float(num) / den if den else None
return float(rate)
def probe_video(path: Path) -> dict:
"""ffprobe a video; return ``{duration_s, fps, width, height}`` (lane A / M1)."""
_require("ffprobe")
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries",
"stream=width,height,avg_frame_rate,r_frame_rate:format=duration",
"-of", "json", str(path)],
check=True, capture_output=True, text=True,
).stdout
data = json.loads(out)
streams = data.get("streams") or []
if not streams:
raise ValueError(f"{path.name}: no video stream found")
stream = streams[0]
# avg_frame_rate is the true average; r_frame_rate is a fallback for odd containers.
fps = _parse_fps(stream.get("avg_frame_rate")) or _parse_fps(stream.get("r_frame_rate")) or 0.0
duration = float(data.get("format", {}).get("duration") or 0.0)
return {
"duration_s": duration,
"fps": float(fps),
"width": int(stream["width"]),
"height": int(stream["height"]),
}
def extract_audio(path: Path, out_wav: Path, sample_rate: int = 16_000) -> Path:
"""Extract a mono ``sample_rate`` 16-bit PCM WAV from ``path`` via ffmpeg (lane A / M1)."""
_require("ffmpeg")
out_wav.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-i", str(path),
"-vn", # drop video
"-ac", "1", # mono
"-ar", str(sample_rate), # resample
"-c:a", "pcm_s16le", # uncompressed PCM (lossless for sync)
str(out_wav)],
check=True,
)
return out_wav
def run_ingest() -> list[dict]:
"""Ingest every video in ``config.RAW_DIR``: probe, register in DB, extract audio.
Entrypoint for ``python -m festival4d ingest``. Returns a per-video summary list.
"""
db.init_engine()
db.init_db()
files = sorted(
p for p in config.RAW_DIR.glob("*") if p.suffix.lower() in _VIDEO_EXTS
)
if not files:
log.warning("ingest: no video files in %s (drop clips there first)", config.RAW_DIR)
return []
existing = {v.filename: v for v in db.get_videos()}
summary: list[dict] = []
for path in files:
info = probe_video(path)
video = existing.get(path.name)
reused = video is not None
if video is None:
video = db.add_video(
filename=path.name,
duration_s=info["duration_s"], fps=info["fps"],
width=info["width"], height=info["height"],
)
wav = audio_wav_path(video.id)
extract_audio(path, wav)
summary.append({
"video_id": video.id, "filename": path.name,
"duration_s": info["duration_s"], "fps": info["fps"],
"width": info["width"], "height": info["height"],
"audio_wav": str(wav), "reused_db_row": reused,
})
log.info("ingest: %s -> video_id=%d (%.1fs, %.2f fps, %dx%d)%s",
path.name, video.id, info["duration_s"], info["fps"],
info["width"], info["height"], " [reused row]" if reused else "")
log.info("ingest: %d video(s) ready; audio in %s", len(summary), config.AUDIO_DIR)
return summary