festifun/backend/festival4d/ingest.py
m3ultra c24af8029e Audio is the master clock + spatial audio free-roam (fresh-eyes opts 1 & feature 1)
Master clock: the selected soundtrack (new GET /api/audio/{id} —
listening-quality stereo AAC, lazily ffmpeg-extracted and cached in
data/work/audio_hq/) plays through WebAudio, and t_global derives from
AudioContext.currentTime. Every <video> is muted picture-only, so hard
seeks and playbackRate corrections are inaudible (the unmuted video
used to warble on every nudge). Graceful fallback to the old
performance.now() clock + <video> audio when a track can't load, with
an in-place upgrade if the decode lands mid-play. Drift is compensated
in the track's playbackRate; _resync() now recomputes from the live
clock since the soundtrack keeps playing while rAF is throttled.

Spatial audio (🎧 3D audio, shown when poses exist): every enabled
camera's track plays through an HRTF PannerNode at its live
reconstructed pose, gains normalized 1/sqrt(N); the listener follows
the viewer camera each frame (free roam, follow-cam, and M9 paths).
Tracks that finish decoding mid-play join the mix in place; toggling,
seeking, rate and enable changes rebuild the graph at the current
t_global with no timeline jump.

The window.__f4d debug hook is now also available in production builds
via localStorage f4dDebug=1 (headless panes throttle rAF; the pump is
the only way to drive the loop there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:02:48 +10:00

164 lines
5.9 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 extract_audio_hq(path: Path, out_m4a: Path) -> Path:
"""Extract a listening-quality stereo AAC track (48 kHz, 192 kbps) from ``path``.
Feeds the frontend's WebAudio master clock (``GET /api/audio/{id}``). AAC-in-m4a is
re-encoded (not stream-copied) so any source codec — including webm/Opus from browser
capture — decodes in every browser's ``decodeAudioData``. Written atomically (tmp +
rename) so a request racing the first extraction never reads a half-written file.
"""
_require("ffmpeg")
out_m4a.parent.mkdir(parents=True, exist_ok=True)
tmp = out_m4a.with_suffix(".tmp.m4a")
subprocess.run(
["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-i", str(path),
"-vn",
"-ac", "2",
"-ar", "48000",
"-c:a", "aac", "-b:a", "192k",
str(tmp)],
check=True,
)
tmp.replace(out_m4a)
return out_m4a
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