festifun/backend/tests/test_audio_sync.py
m3ultra 95b71788a3 Sync solver: cache per-signal FFTs across the pairwise GCC-PHAT stage
pairwise_offsets now computes each signal's rFFT once at a shared padded
length (2*max_len) and reuses it for every pair: O(K) forward FFTs +
cheap per-pair spectrum products instead of O(K^2) full FFTs. Each
pair's lag search stays capped at +/-(la+lb)/2 — the same window the
per-pair transform used — and gcc_phat() itself is unchanged for
callers (drift windows, tests). Measured 1.8x on 6 cams x 10 min;
the advantage grows with camera count.

New test locks the cached path against direct gcc_phat, including
unequal-length signals where the padded sizes genuinely differ.

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

263 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Audio-sync tests (spec M1, lane A).
Pure-numpy where possible (no ffmpeg): GCC-PHAT sign/precision, pairwise+global solve
recovering the M0 known offsets (±10 ms), disconnected components → None (pitfall #5),
cycle-consistency rejection, drift recovery (±3 ppm), and the full ``run_sync`` persist +
export path against synthesized WAVs. One ffmpeg-gated test runs the literal spec M1
acceptance: ``synthetic -> ingest -> sync`` recovers ground truth.
"""
from __future__ import annotations
import json
import shutil
import numpy as np
import pytest
from festival4d import audio_sync, config, db, ingest, synthetic
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _broadband(n: int, seed: int = 0) -> np.ndarray:
"""Seeded, mildly band-limited noise — a sharp, unambiguous correlation target."""
rng = np.random.default_rng(seed)
x = rng.standard_normal(n)
return np.convolve(x, np.ones(4) / 4.0, mode="same")
def _delay_samples(x: np.ndarray, d: int) -> np.ndarray:
"""Return ``y`` with ``y[n] = x[n - d]`` (x delayed by d samples, zero-filled)."""
y = np.zeros_like(x)
if d >= 0:
y[d:] = x[:len(x) - d] if d else x
else:
y[:d] = x[-d:]
return y
def _clean_audio_dir() -> None:
"""Remove any WAVs left in the shared session audio dir (ids are reused after reset_db)."""
config.AUDIO_DIR.mkdir(parents=True, exist_ok=True)
for wav in config.AUDIO_DIR.glob("*.wav"):
wav.unlink()
# ---------------------------------------------------------------------------
# GCC-PHAT
# ---------------------------------------------------------------------------
def test_gcc_phat_recovers_known_delay_positive_and_negative():
sr = 16_000
ref = _broadband(sr * 4)
for d in (0, 137, -211, 800):
sig = _delay_samples(ref, d)
offset_s, conf = audio_sync.gcc_phat(sig, ref, sr)
# positive offset_s means sig lags ref, i.e. matches a positive sample delay
assert abs(offset_s - d / sr) < 0.5 / sr, (d, offset_s * sr)
assert conf > 5.0
def test_gcc_phat_confidence_drops_with_noise():
sr = 16_000
ref = _broadband(sr * 4, seed=1)
clean = _delay_samples(ref, 300)
rng = np.random.default_rng(2)
noisy = clean + 3.0 * rng.standard_normal(len(clean))
_, c_clean = audio_sync.gcc_phat(clean, ref, sr)
_, c_noisy = audio_sync.gcc_phat(noisy, ref, sr)
assert c_clean > c_noisy
# ---------------------------------------------------------------------------
# pairwise + global solve
# ---------------------------------------------------------------------------
def test_pairwise_and_solve_recover_synthetic_offsets():
"""The M0 known offsets (0/+1370/842 ms) recovered within the spec's ±10 ms."""
sr = config.AUDIO_SAMPLE_RATE
master, _ = synthetic.synth_master_audio(sr)
signals = {
i + 1: synthetic.slice_for_offset(master, sr, off, config.SYNTH_DURATION_S)
for i, off in enumerate(config.SYNTH_OFFSETS_MS)
}
edges = audio_sync.pairwise_offsets(signals, sr)
solved = audio_sync.solve_global_offsets(edges, list(signals))
for i, off in enumerate(config.SYNTH_OFFSETS_MS):
assert solved[i + 1] is not None
assert abs(solved[i + 1] - off) <= 10.0, (off, solved[i + 1])
def test_pairwise_matches_direct_gcc_phat_with_unequal_lengths():
"""The cached-FFT pairwise path agrees with a direct per-pair gcc_phat call.
Signals get different lengths on purpose: the shared padded size (2·max_len) then
differs from the per-pair size (la+lb), which is exactly where the two paths could
diverge if the lag windowing were wrong.
"""
sr = 16_000
master = _broadband(sr * 8, seed=7)
signals = {
1: master[: sr * 6],
2: _delay_samples(master, 900)[: sr * 4],
3: _delay_samples(master, -1500)[: sr * 5],
}
edges = audio_sync.pairwise_offsets(signals, sr)
assert len(edges) == 3
for e in edges:
direct_delay, direct_conf = audio_sync.gcc_phat(signals[e["a"]], signals[e["b"]], sr)
assert abs(e["offset_s"] - -direct_delay) < 1.0 / sr, e
assert e["confidence"] > 5.0 and direct_conf > 5.0
def test_pairwise_edge_sign_is_relative_start_offset():
"""Edge ``offset_s`` = offset_a offset_b (seconds)."""
sr = config.AUDIO_SAMPLE_RATE
master, _ = synthetic.synth_master_audio(sr)
a = synthetic.slice_for_offset(master, sr, 500.0, 6.0) # id 1, offset +500 ms
b = synthetic.slice_for_offset(master, sr, -300.0, 6.0) # id 2, offset 300 ms
edges = audio_sync.pairwise_offsets({1: a, 2: b}, sr)
assert len(edges) == 1
assert abs(edges[0]["offset_s"] * 1000.0 - (500.0 - -300.0)) <= 10.0
def test_solve_disconnected_component_gets_none():
"""A video with no usable edges is unsynced (offset None), not guessed (pitfall #5)."""
edges = [
{"a": 1, "b": 2, "offset_s": -0.5, "confidence": 30.0},
{"a": 2, "b": 3, "offset_s": -0.4, "confidence": 30.0},
]
solved = audio_sync.solve_global_offsets(edges, [1, 2, 3, 4])
assert solved[1] == 0.0
assert abs(solved[2] - 500.0) < 1.0
assert abs(solved[3] - 900.0) < 1.0
assert solved[4] is None # isolated → None
def test_solve_rejects_cycle_inconsistent_edge():
"""A grossly inconsistent edge (>50 ms residual) is rejected; the rest still solve."""
# True offsets: 1=0, 2=+500 ms, 3=+900 ms. Edge 1-3 is corrupted (should be 0.9 s).
edges = [
{"a": 1, "b": 2, "offset_s": -0.5, "confidence": 20.0},
{"a": 2, "b": 3, "offset_s": -0.4, "confidence": 20.0},
{"a": 1, "b": 3, "offset_s": +5.0, "confidence": 20.0}, # bad
]
solved = audio_sync.solve_global_offsets(edges, [1, 2, 3])
assert solved[1] == 0.0
assert abs(solved[2] - 500.0) < 1.0
assert abs(solved[3] - 900.0) < 1.0 # recovered despite the bad edge
def test_low_confidence_edges_are_dropped():
"""Edges below the confidence floor don't connect a component."""
edges = [{"a": 1, "b": 2, "offset_s": -0.5, "confidence": 1.01}]
solved = audio_sync.solve_global_offsets(edges, [1, 2])
# 1 is the lone reference at 0; 2 has only a rejected edge → None
assert solved[1] == 0.0 and solved[2] is None
# ---------------------------------------------------------------------------
# drift
# ---------------------------------------------------------------------------
def _make_drifted(ref: np.ndarray, sr: int, drift_ppm: float, offset_s: float = 0.0) -> np.ndarray:
"""Build v from the config timebase: v(τ) = ref((1 d)·τ + offset_s), d = ppm·1e6."""
d = drift_ppm * 1e-6
local = np.arange(len(ref))
src = (1.0 - d) * local + offset_s * sr
return np.interp(src, np.arange(len(ref)), ref, left=0.0, right=0.0)
def test_estimate_drift_recovers_known_ppm():
sr = 8_000
ref = _broadband(sr * 150, seed=5) # 150 s so several 30 s-spaced windows fit
for ppm in (30.0, -22.0):
v = _make_drifted(ref, sr, ppm)
est = audio_sync.estimate_drift(v, ref, sr)
assert abs(est - ppm) <= 3.0, (ppm, est)
def test_estimate_drift_zero_within_deadband():
sr = 8_000
ref = _broadband(sr * 150, seed=6)
assert audio_sync.estimate_drift(ref, ref, sr) == 0.0 # identical
assert audio_sync.estimate_drift(_make_drifted(ref, sr, 1.5), ref, sr) == 0.0 # < 5 ppm
def test_estimate_drift_returns_zero_when_too_short():
sr = 16_000
ref = _broadband(sr * 15) # < 2 windows at 10 s / 30 s hop
assert audio_sync.estimate_drift(ref, ref, sr) == 0.0
# ---------------------------------------------------------------------------
# run_sync orchestration
# ---------------------------------------------------------------------------
def test_run_sync_persists_and_exports():
"""Full run_sync against synthesized WAVs (no ffmpeg): DB + sync.json, ±10 ms."""
sr = config.AUDIO_SAMPLE_RATE
master, _ = synthetic.synth_master_audio(sr)
db.init_engine()
db.reset_db()
_clean_audio_dir()
gt = {}
for i, off in enumerate(config.SYNTH_OFFSETS_MS):
v = db.add_video(filename=f"cam{i}.mp4", duration_s=config.SYNTH_DURATION_S,
fps=30.0, width=640, height=360)
clip = synthetic.slice_for_offset(master, sr, off, config.SYNTH_DURATION_S)
synthetic.write_wav(ingest.audio_wav_path(v.id), clip, sr)
gt[v.id] = off
solution = audio_sync.run_sync()
for v in db.get_videos():
assert v.offset_ms is not None
assert abs(v.offset_ms - gt[v.id]) <= 10.0, (gt[v.id], v.offset_ms)
assert v.drift_ppm == 0.0
assert 0.0 <= v.sync_confidence <= 1.0
ref = min(gt)
assert solution["reference_video_id"] == ref
assert db.get_video(ref).sync_confidence == 1.0
exported = json.loads(config.SYNC_JSON.read_text())
assert exported["reference_video_id"] == ref
assert len(exported["edges"]) == 3
def test_run_sync_marks_video_without_audio_unsynced():
"""A registered video with no extracted WAV is left offset=None, not guessed."""
sr = config.AUDIO_SAMPLE_RATE
master, _ = synthetic.synth_master_audio(sr)
db.init_engine()
db.reset_db()
_clean_audio_dir()
v0 = db.add_video(filename="cam0.mp4", duration_s=20.0, fps=30.0, width=640, height=360)
v1 = db.add_video(filename="cam1.mp4", duration_s=20.0, fps=30.0, width=640, height=360)
no_audio = db.add_video(filename="cam2.mp4", duration_s=20.0, fps=30.0, width=640, height=360)
synthetic.write_wav(ingest.audio_wav_path(v0.id),
synthetic.slice_for_offset(master, sr, 0.0, 20.0), sr)
synthetic.write_wav(ingest.audio_wav_path(v1.id),
synthetic.slice_for_offset(master, sr, 1370.0, 20.0), sr)
# no WAV for `no_audio`
audio_sync.run_sync()
assert db.get_video(no_audio.id).offset_ms is None
assert db.get_video(no_audio.id).sync_confidence is None
assert db.get_video(v0.id).offset_ms == 0.0
# ---------------------------------------------------------------------------
# ffmpeg-gated: the literal spec M1 acceptance (synthetic -> ingest -> sync)
# ---------------------------------------------------------------------------
@pytest.mark.skipif(shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
reason="ffmpeg/ffprobe required for the end-to-end sync acceptance")
def test_synthetic_ingest_sync_recovers_ground_truth():
synthetic.build(duration_s=8.0, run_ffmpeg=True) # into the temp data dir (conftest)
ingest.run_ingest()
audio_sync.run_sync()
gt = {v["filename"]: v["offset_ms"]
for v in json.loads(config.GROUND_TRUTH_JSON.read_text())["videos"]}
for v in db.get_videos():
assert v.offset_ms is not None, v.filename
assert abs(v.offset_ms - gt[v.filename]) <= 10.0, (v.filename, v.offset_ms, gt[v.filename])
assert abs(v.drift_ppm) <= 3.0