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>
164 lines
5.9 KiB
Python
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
|