Fills events_ai.py against the frozen M7 contract:
- detect_candidates: RMS x spectral-flux, local-max over a 10s neighborhood,
>=P90, with an AC-RMS (std) silence/DC floor so flat audio yields no events.
Matches the fixture ground-truth pulses [3,10,17] within ~0.02s (<0.5s).
- GeminiClassifier (native video), ClaudeClassifier (claude-opus-4-8,
messages.parse, adaptive thinking, no temperature), LocalClassifier
(OpenAI-compatible, 6 frames, 1 retry). SDKs imported lazily.
- get_classifier: FESTIVAL4D_CLASSIFIER selection; unconfigured -> None
(candidates-only, logged).
- prepare_inputs: ffmpeg 3s clip (<=720p, audio kept) + 6 JPEGs (<=768px).
- run_events: reference-audio detect -> classify (insert-once in final state,
since db.py has no update-event helper); per-candidate exception isolation;
whole-track replaces machine events (keeps user edits), windowed is additive.
test_events_ai.py: candidate-vs-ground-truth (no ffmpeg/API), stub-classifier
orchestration, exception isolation, degradation, provider selection, silence.
Also updates the now-obsolete api stub test (test_detect_events_endpoint) to the
real M3 {result, events} shape; frozen api.py unchanged (see CR-1). pytest: 90 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""Tests for events_ai.py (lane D / spec M7).
|
|
|
|
No test hits a real API or ffmpeg: candidate detection runs on the synthetic fixture's
|
|
in-memory audio, and the classifier orchestration is exercised with stub classifiers and a
|
|
monkeypatched input-preparer. This mirrors spec M7's acceptance:
|
|
- candidates land within ±0.5 s of the ground-truth pulse times, and
|
|
- the two-stage loop (detect -> classify) works independent of any provider.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from festival4d import config, db, events_ai, synthetic
|
|
from festival4d.events_ai import (
|
|
ClaudeClassifier,
|
|
GeminiClassifier,
|
|
LocalClassifier,
|
|
MomentClassification,
|
|
MomentClassifier,
|
|
detect_candidates,
|
|
get_classifier,
|
|
run_events,
|
|
)
|
|
|
|
SR = config.SYNTH_AUDIO_SR
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers / stub classifiers (no network)
|
|
# ---------------------------------------------------------------------------
|
|
def _reference_audio():
|
|
"""cam0's audio: the master signal sliced at offset 0 (local == global time)."""
|
|
master, gt = synthetic.synth_master_audio(SR)
|
|
audio = synthetic.slice_for_offset(master, SR, 0.0, config.SYNTH_DURATION_S)
|
|
return audio, gt["pulse_times_global_s"]
|
|
|
|
|
|
class StubClassifier:
|
|
"""Always returns the same valid classification; counts calls."""
|
|
|
|
def __init__(self, event_type: str = "bass_drop") -> None:
|
|
self.calls = 0
|
|
self.event_type = event_type
|
|
|
|
def classify(self, clip_path: Path, frames: list[Path]) -> MomentClassification:
|
|
self.calls += 1
|
|
return MomentClassification(
|
|
event_type=self.event_type, confidence=0.9, description="stub classification"
|
|
)
|
|
|
|
|
|
class FlakyClassifier:
|
|
"""Raises on a chosen call to exercise per-candidate exception isolation."""
|
|
|
|
def __init__(self, fail_on_call: int = 2) -> None:
|
|
self.calls = 0
|
|
self.fail_on_call = fail_on_call
|
|
|
|
def classify(self, clip_path: Path, frames: list[Path]) -> MomentClassification:
|
|
self.calls += 1
|
|
if self.calls == self.fail_on_call:
|
|
raise RuntimeError("simulated provider failure")
|
|
return MomentClassification(event_type="pyro", confidence=0.6, description="ok")
|
|
|
|
|
|
def _fresh_reference_db(offset_ms: float = 0.0) -> None:
|
|
"""A clean DB with a single reference video (matching cam0)."""
|
|
db.init_engine()
|
|
db.reset_db()
|
|
db.add_video(
|
|
filename="cam0.mp4", duration_s=config.SYNTH_DURATION_S, fps=config.SYNTH_FPS,
|
|
width=config.SYNTH_VIDEO_W, height=config.SYNTH_VIDEO_H,
|
|
offset_ms=offset_ms, drift_ppm=0.0, sync_confidence=1.0,
|
|
)
|
|
|
|
|
|
def _stub_run(monkeypatch, classifier):
|
|
"""Wire run_events to run real detection on fixture audio + a stub classifier."""
|
|
audio, _ = _reference_audio()
|
|
monkeypatch.setattr(events_ai, "_load_reference_audio", lambda video: (audio, SR))
|
|
monkeypatch.setattr(events_ai, "get_classifier", lambda name=None: classifier)
|
|
monkeypatch.setattr(
|
|
events_ai, "prepare_inputs",
|
|
lambda t: (Path("/nonexistent/clip.mp4"), [Path("/nonexistent/frame_00.jpg")]),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Candidate detection (the acceptance-critical numeric test)
|
|
# ---------------------------------------------------------------------------
|
|
def test_detect_candidates_matches_ground_truth():
|
|
audio, pulses = _reference_audio()
|
|
cands = detect_candidates(audio, SR)
|
|
|
|
# Recall: every ground-truth pulse has a candidate within ±0.5 s.
|
|
for pulse in pulses:
|
|
nearest = min(abs(c - pulse) for c in cands)
|
|
assert nearest < 0.5, f"pulse {pulse}s unmatched; nearest candidate {nearest:.3f}s away"
|
|
|
|
# Precision: no spurious candidates — each maps to a distinct ground-truth pulse.
|
|
for c in cands:
|
|
nearest = min(abs(c - pulse) for pulse in pulses)
|
|
assert nearest < 0.5, f"spurious candidate at {c:.3f}s ({nearest:.3f}s from any pulse)"
|
|
assert len(cands) == len(pulses), (cands, pulses)
|
|
|
|
|
|
def test_detect_candidates_short_audio_is_empty():
|
|
import numpy as np
|
|
|
|
assert detect_candidates(np.zeros(16, dtype=np.float32), SR) == []
|
|
|
|
|
|
def test_detect_candidates_silent_or_flat_audio_is_empty():
|
|
"""Silence / DC / a bare noise floor must yield NO candidates, never fabricate events."""
|
|
import numpy as np
|
|
|
|
n = SR * 6
|
|
assert detect_candidates(np.zeros(n, dtype=np.float32), SR) == [] # digital silence
|
|
assert detect_candidates(np.full(n, 0.5, dtype=np.float32), SR) == [] # constant / DC
|
|
rng = np.random.default_rng(0)
|
|
assert detect_candidates((1e-4 * rng.standard_normal(n)).astype(np.float32), SR) == [] # noise floor
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Frozen contract sanity (MomentClassification / MomentClassifier)
|
|
# ---------------------------------------------------------------------------
|
|
def test_moment_classification_validates_bounds_and_enum():
|
|
ok = MomentClassification(event_type="pyro", confidence=0.5, description="x")
|
|
assert ok.event_type == "pyro"
|
|
with pytest.raises(ValidationError):
|
|
MomentClassification(event_type="pyro", confidence=1.5, description="x") # >1
|
|
with pytest.raises(ValidationError):
|
|
MomentClassification(event_type="not_a_type", confidence=0.5, description="x")
|
|
|
|
|
|
def test_stub_satisfies_classifier_protocol():
|
|
assert isinstance(StubClassifier(), MomentClassifier)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provider selection + degradation (no network — constructors don't call out)
|
|
# ---------------------------------------------------------------------------
|
|
def test_get_classifier_unconfigured_returns_none(monkeypatch):
|
|
for var in ("GEMINI_API_KEY", "ANTHROPIC_API_KEY",
|
|
"FESTIVAL4D_OPENAI_BASE_URL", "FESTIVAL4D_OPENAI_MODEL", "FESTIVAL4D_OPENAI_KEY"):
|
|
monkeypatch.delenv(var, raising=False)
|
|
assert get_classifier("gemini") is None
|
|
assert get_classifier("claude") is None
|
|
assert get_classifier("local") is None
|
|
|
|
|
|
def test_get_classifier_unknown_provider_returns_none():
|
|
assert get_classifier("does-not-exist") is None
|
|
|
|
|
|
def test_get_classifier_defaults_to_gemini(monkeypatch):
|
|
monkeypatch.delenv("FESTIVAL4D_CLASSIFIER", raising=False)
|
|
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
|
assert isinstance(get_classifier(), GeminiClassifier)
|
|
|
|
|
|
def test_get_classifier_local_when_configured(monkeypatch):
|
|
monkeypatch.setenv("FESTIVAL4D_OPENAI_BASE_URL", "http://localhost:11434/v1")
|
|
monkeypatch.setenv("FESTIVAL4D_OPENAI_MODEL", "qwen2.5-vl")
|
|
monkeypatch.delenv("FESTIVAL4D_OPENAI_KEY", raising=False)
|
|
clf = get_classifier("local")
|
|
assert isinstance(clf, LocalClassifier)
|
|
assert isinstance(clf, MomentClassifier)
|
|
|
|
|
|
def test_get_classifier_claude_when_configured(monkeypatch):
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
|
assert isinstance(get_classifier("claude"), ClaudeClassifier)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Orchestration loop (stub classifier — no API, no ffmpeg)
|
|
# ---------------------------------------------------------------------------
|
|
def test_run_events_classifies_all_candidates(monkeypatch):
|
|
_fresh_reference_db()
|
|
stub = StubClassifier(event_type="bass_drop")
|
|
_stub_run(monkeypatch, stub)
|
|
|
|
result = run_events()
|
|
|
|
assert result["candidates"] == 3
|
|
assert result["classified"] == 3
|
|
assert result["candidates_only"] == 0
|
|
assert result["classifier_configured"] is True
|
|
assert stub.calls == 3
|
|
|
|
events = db.get_events()
|
|
assert len(events) == 3
|
|
assert all(e.source == "ai" for e in events)
|
|
assert all(e.event_type == "bass_drop" for e in events)
|
|
# Candidate times map to t_global (reference offset 0) near the fixture pulses.
|
|
times = sorted(e.t_global_s for e in events)
|
|
for got, pulse in zip(times, (3.0, 10.0, 17.0)):
|
|
assert abs(got - pulse) < 0.5
|
|
|
|
|
|
def test_run_events_unconfigured_stores_candidates_only(monkeypatch):
|
|
_fresh_reference_db()
|
|
audio, _ = _reference_audio()
|
|
monkeypatch.setattr(events_ai, "_load_reference_audio", lambda video: (audio, SR))
|
|
monkeypatch.setattr(events_ai, "get_classifier", lambda name=None: None)
|
|
# Degraded path must never prepare inputs. A *raised* tripwire would be swallowed by
|
|
# _classify_one's `except Exception`; record calls with a side effect instead.
|
|
prepared: list = []
|
|
monkeypatch.setattr(events_ai, "prepare_inputs", lambda t: prepared.append(t))
|
|
|
|
result = run_events()
|
|
|
|
assert prepared == [] # prepare_inputs was never called when unconfigured
|
|
|
|
assert result["candidates"] == 3
|
|
assert result["classified"] == 0
|
|
assert result["candidates_only"] == 3
|
|
assert result["classifier_configured"] is False
|
|
|
|
events = db.get_events()
|
|
assert len(events) == 3
|
|
assert all(e.source == "audio_auto" for e in events)
|
|
assert all(e.event_type == "candidate" for e in events)
|
|
|
|
|
|
def test_run_events_isolates_per_candidate_failure(monkeypatch):
|
|
_fresh_reference_db()
|
|
flaky = FlakyClassifier(fail_on_call=2) # 2nd candidate fails
|
|
_stub_run(monkeypatch, flaky)
|
|
|
|
result = run_events()
|
|
|
|
assert result["candidates"] == 3
|
|
assert result["classified"] == 2 # two succeeded
|
|
assert result["candidates_only"] == 1 # the failed one fell back to a candidate
|
|
assert flaky.calls == 3 # the batch was not aborted
|
|
|
|
events = db.get_events()
|
|
assert sum(e.source == "ai" for e in events) == 2
|
|
assert sum(e.source == "audio_auto" for e in events) == 1
|
|
|
|
|
|
def test_run_events_whole_track_replaces_machine_events_keeps_user(monkeypatch):
|
|
_fresh_reference_db()
|
|
# Pre-existing events: a stale AI one and a user correction.
|
|
db.add_event(t_global_s=99.0, event_type="other", source="ai", description="stale")
|
|
db.add_event(t_global_s=5.0, event_type="crowd_wave", source="user", description="mine")
|
|
_stub_run(monkeypatch, StubClassifier())
|
|
|
|
run_events()
|
|
|
|
events = db.get_events()
|
|
user = [e for e in events if e.source == "user"]
|
|
ai = [e for e in events if e.source == "ai"]
|
|
assert len(user) == 1 and user[0].description == "mine" # user edit preserved
|
|
assert len(ai) == 3 # fresh detection, stale AI gone
|
|
assert not any(e.description == "stale" for e in events)
|
|
|
|
|
|
def test_run_events_windowed_adds_in_window_and_clears_nothing(monkeypatch):
|
|
_fresh_reference_db()
|
|
# A machine event OUTSIDE the window must survive — windowed detection is purely additive.
|
|
db.add_event(t_global_s=3.0, event_type="bass_drop", source="ai", description="pre-existing")
|
|
_stub_run(monkeypatch, StubClassifier())
|
|
|
|
result = run_events(t_global_s=10.0, window_s=4.0) # window [8, 12] -> only the ~10 s pulse
|
|
|
|
assert result["candidates"] == 1
|
|
assert result["window"] == {"t_global_s": 10.0, "window_s": 4.0}
|
|
events = db.get_events()
|
|
assert any(e.description == "pre-existing" for e in events) # out-of-window event preserved
|
|
assert len(events) == 2
|
|
new = [e for e in events if e.description != "pre-existing"]
|
|
assert len(new) == 1 and abs(new[0].t_global_s - 10.0) < 0.5
|
|
|
|
|
|
def test_run_events_no_videos_degrades(monkeypatch):
|
|
db.init_engine()
|
|
db.reset_db()
|
|
result = run_events()
|
|
assert result["candidates"] == 0
|
|
assert "note" in result
|
|
assert db.get_events() == []
|