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>
263 lines
11 KiB
Python
263 lines
11 KiB
Python
"""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·1e−6."""
|
||
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
|