lane D (M7): audio moment detection + pluggable AI classification

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>
This commit is contained in:
m3ultra 2026-07-16 08:34:56 +10:00
parent 4399bc90e2
commit 5c2d7c6ea3
5 changed files with 804 additions and 35 deletions

View File

@ -4,9 +4,9 @@ Two stages: cheap audio candidates first, then a vision LLM on candidates only.
This module defines the **FROZEN classifier contract** :class:`MomentClassification`
(Pydantic model) and :class:`MomentClassifier` (Protocol) exactly as spec M7. Lanes and
integration depend on these shapes. Everything else here is a STUB that lane D fills:
candidate detection, the provider classes, provider selection, and the ``run_events``
entrypoint (called by both ``cli.py`` and ``api.py``'s ``POST /api/events/detect``).
integration depend on these shapes. Lane D fills the rest: candidate detection, the provider
classes, provider selection, and the ``run_events`` entrypoint (called by both ``cli.py`` and
``api.py``'s ``POST /api/events/detect``).
Provider selection (lane D): ``FESTIVAL4D_CLASSIFIER=gemini|claude|local`` (default
``gemini``). Inputs prepared once per candidate: a ~3 s MP4 clip around ``t_global`` from
@ -15,19 +15,31 @@ behavior: catch exceptions per candidate (one failure never aborts the batch); i
selected provider is unconfigured, run candidate detection only (``event_type='candidate'``)
and log that classification was skipped.
The module imports with only ``pydantic`` present provider SDKs (``google-genai``,
``anthropic``, ``openai``) are imported lazily inside the provider bodies, never at load.
The module imports with only ``pydantic`` present heavy numeric libs (``numpy``,
``librosa``, ``scipy``, ``soundfile``) and provider SDKs (``google-genai``, ``anthropic``,
``openai``) are imported lazily inside the functions that need them, never at load.
DB note (lane D): ``db.py`` (frozen) exposes ``add_event`` and ``clear_events`` but *no*
update-event helper, so instead of "insert audio_auto candidate, later update to ai" this
module inserts each event **once in its final state** ``source='ai'`` with the classified
type when a provider succeeds, else ``source='audio_auto'`` with ``event_type='candidate'``.
The end state in the DB is identical to the spec's two-step description.
"""
from __future__ import annotations
import base64
import logging
import os
import shutil
import subprocess
from pathlib import Path
from typing import Literal, Protocol, runtime_checkable
from pydantic import BaseModel, Field
from festival4d import config, db
log = logging.getLogger("festival4d.events_ai")
# The closed set of moment classes (spec M7). Lane C colors timeline markers by these.
@ -40,6 +52,25 @@ EVENT_TYPES: tuple[str, ...] = (
"artist_moment", "light_show", "quiet_moment", "other",
)
# ---------------------------------------------------------------------------
# Tuning constants (lane D). Detection is verified against the synthetic fixture's
# ground-truth pulse times (spec M0/M7); see backend/tests/test_events_ai.py.
# ---------------------------------------------------------------------------
STFT_HOP = 512 # librosa frame hop (samples)
STFT_FRAME = 2048 # RMS window length (samples)
NEIGHBORHOOD_S = 10.0 # a candidate must be the max over this centered window
PEAK_PERCENTILE = 90.0 # ...and above this percentile of the detection function
MERGE_S = 1.0 # merge peaks closer than this, keeping the strongest
MIN_SIGNAL_STD = 1e-3 # AC-RMS floor (~60 dBFS): silence AND constant/DC have ~0 std
CANDIDATE_DURATION_S = 1.0 # stored event duration (a moment, not an interval)
DEFAULT_WINDOW_S = 6.0 # window width when run_events is given only t_global_s
CLIP_SECONDS = 3.0 # length of the classifier clip (spec M7: ~3 s)
NUM_FRAMES = 6 # sampled JPEGs for providers without video input
FRAME_LONG_EDGE = 768 # max long-edge px for sampled frames (spec M7: ≤768)
CLIP_MAX_HEIGHT = 720 # clip capped at ≤720p (spec M7)
AUDIO_EXTRACT_SR = 48_000 # sample rate when extracting reference audio via ffmpeg
class MomentClassification(BaseModel):
"""Structured classifier output (FROZEN CONTRACT, spec M7)."""
@ -58,8 +89,17 @@ class MomentClassifier(Protocol):
...
_CLASSIFY_PROMPT = (
"This is a ~3-second clip from a concert, captured around a detected audio spike. "
"Classify the single most salient moment in it. Choose event_type from: "
"bass_drop, pyro, confetti, crowd_wave, artist_moment, light_show, quiet_moment, other. "
"Give a confidence in [0, 1] and one short human-readable sentence describing what happens."
)
# ---------------------------------------------------------------------------
# Provider stubs (lane D fills bodies; classifiers import their SDK lazily).
# Provider classes (lane D). Each imports its SDK lazily so the module loads with
# only pydantic present; construction reads the key/endpoint from the environment.
# ---------------------------------------------------------------------------
class GeminiClassifier:
"""Default provider — Gemini 2.5 Flash, native video input (``GEMINI_API_KEY``)."""
@ -67,78 +107,439 @@ class GeminiClassifier:
model = "gemini-2.5-flash"
def __init__(self) -> None:
raise NotImplementedError("lane D (M7): implement GeminiClassifier")
from google import genai
self._genai = genai
self._client = genai.Client() # reads GEMINI_API_KEY from the environment
def classify(self, clip_path: Path, frames: list[Path]) -> MomentClassification:
raise NotImplementedError("lane D (M7): implement GeminiClassifier.classify")
genai = self._genai
clip_bytes = Path(clip_path).read_bytes()
resp = self._client.models.generate_content(
model=self.model,
contents=[
genai.types.Part.from_bytes(data=clip_bytes, mime_type="video/mp4"),
_CLASSIFY_PROMPT,
],
config={
"response_mime_type": "application/json",
"response_schema": MomentClassification,
},
)
return MomentClassification.model_validate_json(resp.text)
class ClaudeClassifier:
"""Optional provider — Claude Opus 4.8, 6 frames as images (``ANTHROPIC_API_KEY``).
Uses ``client.messages.parse(..., thinking={'type': 'adaptive'},
output_format=MomentClassification)``; do **not** pass ``temperature`` (rejected on
Opus 4.8). Model id ``claude-opus-4-8`` (never date-suffixed).
output_format=MomentClassification)`` -> ``resp.parsed_output``; does **not** pass
``temperature`` (rejected on Opus 4.8). Model id ``claude-opus-4-8`` (never date-suffixed).
"""
model = "claude-opus-4-8"
def __init__(self) -> None:
raise NotImplementedError("lane D (M7): implement ClaudeClassifier")
import anthropic
self._client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
def classify(self, clip_path: Path, frames: list[Path]) -> MomentClassification:
raise NotImplementedError("lane D (M7): implement ClaudeClassifier.classify")
content: list[dict] = []
for fp in frames[:NUM_FRAMES]:
data = base64.standard_b64encode(Path(fp).read_bytes()).decode("ascii")
content.append({
"type": "image",
"source": {"type": "base64", "media_type": "image/jpeg", "data": data},
})
content.append({"type": "text", "text": _CLASSIFY_PROMPT})
resp = self._client.messages.parse(
model=self.model,
max_tokens=1024,
thinking={"type": "adaptive"},
output_format=MomentClassification,
messages=[{"role": "user", "content": content}],
)
return resp.parsed_output
class LocalClassifier:
"""Offline/free provider — any OpenAI-compatible vision endpoint (Ollama/LM Studio/
OpenRouter). Config: ``FESTIVAL4D_OPENAI_BASE_URL``, ``FESTIVAL4D_OPENAI_KEY``,
``FESTIVAL4D_OPENAI_MODEL``. Sends the 6 frames as image parts; validates JSON with one
retry on failure.
retry on failure (local models are sloppier about JSON).
"""
def __init__(self) -> None:
raise NotImplementedError("lane D (M7): implement LocalClassifier")
from openai import OpenAI
base_url = os.environ["FESTIVAL4D_OPENAI_BASE_URL"]
# Local servers (Ollama/LM Studio) accept any non-empty key; OpenRouter needs a real one.
api_key = os.environ.get("FESTIVAL4D_OPENAI_KEY") or "not-needed"
self._model = os.environ["FESTIVAL4D_OPENAI_MODEL"]
self._client = OpenAI(base_url=base_url, api_key=api_key)
def classify(self, clip_path: Path, frames: list[Path]) -> MomentClassification:
raise NotImplementedError("lane D (M7): implement LocalClassifier.classify")
from pydantic import ValidationError
schema = MomentClassification.model_json_schema()
content: list[dict] = [{
"type": "text",
"text": (
_CLASSIFY_PROMPT
+ " Respond with ONLY a JSON object matching this schema: "
+ repr(schema)
),
}]
for fp in frames[:NUM_FRAMES]:
b64 = base64.standard_b64encode(Path(fp).read_bytes()).decode("ascii")
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
})
messages = [{"role": "user", "content": content}]
last_exc: Exception | None = None
for attempt in range(2): # one retry on validation failure
resp = self._client.chat.completions.create(
model=self._model,
messages=messages,
response_format={"type": "json_object"},
)
text = resp.choices[0].message.content or ""
try:
return MomentClassification.model_validate_json(text)
except ValidationError as exc:
last_exc = exc
log.warning("events: local classifier returned invalid JSON (attempt %d)",
attempt + 1)
assert last_exc is not None
raise last_exc
_PROVIDERS: dict[str, tuple[type, tuple[str, ...]]] = {
# name -> (class, required environment variables)
"gemini": (GeminiClassifier, ("GEMINI_API_KEY",)),
"claude": (ClaudeClassifier, ("ANTHROPIC_API_KEY",)),
"local": (LocalClassifier, ("FESTIVAL4D_OPENAI_BASE_URL", "FESTIVAL4D_OPENAI_MODEL")),
}
def get_classifier(name: str | None = None) -> MomentClassifier | None:
"""Select a provider by name or ``FESTIVAL4D_CLASSIFIER`` (default ``gemini``).
Returns a ready classifier, or ``None`` if the selected provider is unconfigured (no
key/endpoint) callers then run candidates-only. STUB lane D implements selection.
key/endpoint) or fails to initialize callers then run candidates-only.
"""
_ = name or os.environ.get("FESTIVAL4D_CLASSIFIER", "gemini")
raise NotImplementedError("lane D (M7): implement get_classifier")
name = (name or os.environ.get("FESTIVAL4D_CLASSIFIER") or "gemini").lower()
entry = _PROVIDERS.get(name)
if entry is None:
log.warning("events: unknown classifier %r; running candidates-only", name)
return None
cls, required = entry
missing = [var for var in required if not os.environ.get(var)]
if missing:
log.info("events: provider %r unconfigured (missing %s); classification skipped, "
"storing candidates only", name, ", ".join(missing))
return None
try:
return cls()
except Exception: # SDK import/construction failure -> degrade, don't crash the batch
log.exception("events: failed to initialize %r classifier; running candidates-only", name)
return None
# ---------------------------------------------------------------------------
# Candidate detection + orchestration (lane D fills bodies).
# Candidate detection (spec M7). Verified against the fixture ground-truth pulses.
# ---------------------------------------------------------------------------
def detect_candidates(audio: "object", sr: int) -> list[float]:
"""Audio candidate moments on the reference track (spec M7, lane D).
Compute RMS energy + spectral-flux onset strength (librosa); return the times
(reference-local seconds) of peaks that are local maxima over a 10 s neighborhood and
above the 90th percentile. Verified against the fixture's ground-truth pulse times
(±0.5 s).
Compute RMS energy and spectral-flux onset strength (librosa), combine into a single
detection function (product bangs dominate in both, so the product isolates them
cleanly from the regular beat floor), and return the times (reference-local seconds) of
peaks that are local maxima over a ~10 s neighborhood and above the 90th percentile.
Verified against the fixture's ground-truth pulse times (±0.5 s).
"""
raise NotImplementedError("lane D (M7): implement detect_candidates")
import librosa
import numpy as np
from scipy.ndimage import maximum_filter1d
y = np.asarray(audio, dtype=np.float32).reshape(-1)
if y.size < STFT_FRAME:
return []
# A reference track with no real moments (silence, constant/DC, or a bare noise floor)
# has ~0 AC energy. The percentile threshold below is purely *relative*, so on such a
# flat signal it would flag ~1 spurious peak per second; gate on AC-RMS (std) first —
# this catches silence and DC alike (peak amplitude does not: a DC offset has a large
# peak but no transients, only STFT-boundary artifacts).
if float(np.std(y)) < MIN_SIGNAL_STD:
return []
rms = librosa.feature.rms(y=y, hop_length=STFT_HOP, frame_length=STFT_FRAME)[0]
flux = librosa.onset.onset_strength(y=y, sr=sr, hop_length=STFT_HOP)
n = min(len(rms), len(flux))
if n == 0:
return []
rms, flux = rms[:n], flux[:n]
eps = 1e-12
strength = (rms / (rms.max() + eps)) * (flux / (flux.max() + eps))
# No transient structure at all (e.g. constant/DC audio -> flat detection function).
if float(strength.max()) <= eps:
return []
times = librosa.frames_to_time(np.arange(n), sr=sr, hop_length=STFT_HOP)
win = max(1, int(round(NEIGHBORHOOD_S * sr / STFT_HOP)))
if win % 2 == 0:
win += 1
# A frame is a local maximum if it equals the max over its centered ~10 s window. Because
# the fixture's bangs are >5 s apart, each is the unique max in its own window — a
# min-distance peak picker would instead merge the 7 s-spaced bangs and drop one.
is_local_max = strength >= (maximum_filter1d(strength, size=win, mode="nearest") - eps)
threshold = float(np.percentile(strength, PEAK_PERCENTILE))
idx = np.where(is_local_max & (strength >= threshold))[0]
# Greedy merge of near-duplicate peaks (plateaus), keeping the strongest.
peaks: list[float] = []
for i in sorted(idx.tolist(), key=lambda j: -strength[j]):
t = float(times[i])
if all(abs(t - p) >= MERGE_S for p in peaks):
peaks.append(t)
return sorted(peaks)
# ---------------------------------------------------------------------------
# Reference-audio loading + classifier-input preparation (ffmpeg).
# ---------------------------------------------------------------------------
def _select_reference(videos: list) -> "object | None":
"""The reference video (spec §2: offset 0). Fall back to the smallest |offset|."""
if not videos:
return None
exact = [v for v in videos if v.offset_ms == 0]
if exact:
return exact[0]
with_offset = [v for v in videos if v.offset_ms is not None]
if with_offset:
return min(with_offset, key=lambda v: abs(v.offset_ms))
return videos[0]
def _load_reference_audio(video) -> "tuple[object, int] | None":
"""Load the reference video's audio as mono float samples.
Independent of lane A: extract the audio straight from the raw video with ffmpeg (a
reused ingest WAV would couple us to lane A's naming). Returns ``(samples, sr)`` or
``None`` if the raw video or ffmpeg is unavailable.
"""
import tempfile
import numpy as np
import soundfile as sf
raw = config.RAW_DIR / video.filename
if not raw.exists():
log.warning("events: raw video %s not found under %s", video.filename, config.RAW_DIR)
return None
if shutil.which("ffmpeg") is None:
log.warning("events: ffmpeg not on PATH; cannot extract reference audio")
return None
with tempfile.TemporaryDirectory() as tmp:
wav = Path(tmp) / "ref.wav"
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-i", str(raw), "-vn", "-ac", "1", "-ar", str(AUDIO_EXTRACT_SR),
"-f", "wav", str(wav),
]
try:
subprocess.run(cmd, check=True, capture_output=True)
except subprocess.CalledProcessError as exc:
log.warning("events: ffmpeg audio extraction failed: %s", exc.stderr.decode("utf-8", "ignore"))
return None
samples, sr = sf.read(str(wav), dtype="float32", always_2d=False)
if getattr(samples, "ndim", 1) > 1:
samples = samples.mean(axis=1)
return np.asarray(samples, dtype=np.float32), int(sr)
def _select_best_video(videos: list, t_global_s: float) -> "tuple[object, float] | None":
"""The video whose 3 s window around ``t_global`` is best-covered (most centered)."""
best = None
best_score = None
best_t = 0.0
for v in videos:
t_video = config.t_video_from_global(t_global_s, v.offset_ms or 0.0, v.drift_ppm or 0.0)
margin = min(t_video, (v.duration_s or 0.0) - t_video) # ≥ CLIP_SECONDS/2 => fully covers
if best_score is None or margin > best_score:
best_score, best, best_t = margin, v, t_video
if best is None:
return None
return best, best_t
def _events_workdir() -> Path:
d = config.WORK_DIR / "events"
d.mkdir(parents=True, exist_ok=True)
return d
def prepare_inputs(t_global_s: float) -> tuple[Path, list[Path]]:
"""Prepare classifier inputs for a candidate: a ~3 s MP4 clip + 6 sampled JPEGs (M7)."""
raise NotImplementedError("lane D (M7): implement prepare_inputs")
"""Prepare classifier inputs for a candidate: a ~3 s MP4 clip + up to 6 sampled JPEGs (M7).
Picks the best-covering video, then uses ffmpeg to cut a 720p clip (audio kept, for
Gemini's native-video path) and sample 6 frames (≤768 px long edge). Raises if ffmpeg
or a usable source video is missing ``run_events`` isolates that per candidate.
"""
if shutil.which("ffmpeg") is None:
raise RuntimeError("ffmpeg not found on PATH — required to prepare classifier inputs")
videos = db.get_videos()
picked = _select_best_video(videos, t_global_s)
if picked is None:
raise RuntimeError("no videos available to prepare classifier inputs")
video, t_video = picked
raw = config.RAW_DIR / video.filename
if not raw.exists():
raise RuntimeError(f"source video {raw} not found")
duration = video.duration_s or (t_video + CLIP_SECONDS)
start = max(0.0, min(t_video - CLIP_SECONDS / 2.0, max(0.0, duration - CLIP_SECONDS)))
workdir = _events_workdir()
tag = f"cand_{t_global_s:0.3f}".replace("-", "m")
clip_path = workdir / f"{tag}.mp4"
frames_dir = workdir / tag
if frames_dir.exists():
for old in frames_dir.glob("frame_*.jpg"):
old.unlink()
frames_dir.mkdir(parents=True, exist_ok=True)
# 3 s clip, downscaled only if taller than 720p, audio preserved, faststart for inline bytes.
subprocess.run(
["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-ss", f"{start:.3f}", "-i", str(raw), "-t", f"{CLIP_SECONDS:.3f}",
"-vf", f"scale=-2:'min({CLIP_MAX_HEIGHT},ih)'",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "28", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "96k", "-movflags", "+faststart", str(clip_path)],
check=True, capture_output=True,
)
# 6 frames evenly across the 3 s window, long edge ≤ 768 px.
scale = (f"scale='if(gt(iw,ih),min({FRAME_LONG_EDGE},iw),-2)':"
f"'if(gt(iw,ih),-2,min({FRAME_LONG_EDGE},ih))'")
fps = NUM_FRAMES / CLIP_SECONDS
subprocess.run(
["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-ss", f"{start:.3f}", "-i", str(raw), "-t", f"{CLIP_SECONDS:.3f}",
"-vf", f"fps={fps},{scale}", "-frames:v", str(NUM_FRAMES),
str(frames_dir / "frame_%02d.jpg")],
check=True, capture_output=True,
)
frames = sorted(frames_dir.glob("frame_*.jpg"))
return clip_path, frames
# ---------------------------------------------------------------------------
# Orchestration (spec M7). Entrypoint for the CLI + POST /api/events/detect.
# ---------------------------------------------------------------------------
def _classify_one(t_global_s: float, classifier: "MomentClassifier | None") -> tuple[str, str, float | None, str | None]:
"""Resolve one candidate to (event_type, source, confidence, description).
No classifier -> a bare ``audio_auto`` candidate. Otherwise classify, isolating any
failure (SDK error, bad clip) by falling back to a candidate one failure never aborts
the batch (spec M7).
"""
if classifier is None:
return "candidate", "audio_auto", None, None
try:
clip_path, frames = prepare_inputs(t_global_s)
result = classifier.classify(clip_path, frames)
return result.event_type, "ai", result.confidence, result.description
except Exception:
log.exception("events: classification failed at t_global=%.3f s; storing as candidate",
t_global_s)
return "candidate", "audio_auto", None, None
def run_events(t_global_s: float | None = None, window_s: float | None = None) -> dict:
"""Detect audio candidates and (if a provider is configured) classify them.
Entrypoint for ``python -m festival4d events`` and ``POST /api/events/detect``. Inserts
events via ``db`` (``source='audio_auto'``; classified ones updated to ``source='ai'``).
Optionally restrict to a single window around ``t_global_s`` of width ``window_s``.
Returns a summary dict. Per-candidate exceptions are isolated (spec M7).
Entrypoint for ``python -m festival4d events`` and ``POST /api/events/detect``. Detects
on the reference track, maps candidate times to ``t_global``, then inserts events via
``db`` (``source='audio_auto'`` for bare candidates, ``source='ai'`` for classified ones;
see the module docstring on why this is a single insert per event). Per-candidate
exceptions are isolated. Returns a summary dict.
Whole-track detection (``t_global_s is None``) is authoritative: it first clears prior
machine-generated events (``audio_auto`` + ``ai``) while preserving user edits. A windowed
call (``t_global_s`` given, optional ``window_s``) only *adds* candidates inside the
window and clears nothing, so it never disturbs the rest of the timeline.
"""
raise NotImplementedError("lane D (M7): implement run_events")
db.init_engine()
db.init_db()
selected = (os.environ.get("FESTIVAL4D_CLASSIFIER") or "gemini").lower()
videos = db.get_videos()
reference = _select_reference(videos)
if reference is None:
log.warning("events: no videos registered; run `synthetic` or `ingest` first")
return _summary(selected, False, 0, 0, 0, None, note="no videos registered")
loaded = _load_reference_audio(reference)
if loaded is None:
return _summary(selected, False, 0, 0, 0, None,
note="no reference audio (missing raw video or ffmpeg)")
audio, sr = loaded
local_times = detect_candidates(audio, sr)
off = reference.offset_ms or 0.0
drift = reference.drift_ppm or 0.0
cand_globals = [config.t_global_from_video(t, off, drift) for t in local_times]
window = None
if t_global_s is not None:
w = window_s if window_s is not None else DEFAULT_WINDOW_S
lo, hi = t_global_s - w / 2.0, t_global_s + w / 2.0
cand_globals = [t for t in cand_globals if lo <= t <= hi]
window = {"t_global_s": t_global_s, "window_s": w}
else:
# Whole-track detection replaces the machine timeline; user events are kept.
db.clear_events("audio_auto")
db.clear_events("ai")
classifier = get_classifier(selected)
n_ai = n_auto = 0
for t_global in cand_globals:
event_type, source, confidence, description = _classify_one(t_global, classifier)
db.add_event(
t_global_s=t_global,
event_type=event_type,
source=source,
duration_s=CANDIDATE_DURATION_S,
confidence=confidence,
description=description,
)
if source == "ai":
n_ai += 1
else:
n_auto += 1
summary = _summary(selected, classifier is not None, len(cand_globals), n_ai, n_auto, window)
log.info("events: reference=%s candidates=%d classified=%d candidates_only=%d provider=%s",
reference.filename, len(cand_globals), n_ai, n_auto,
selected if classifier is not None else f"{selected} (unconfigured)")
return summary
def _summary(provider: str, configured: bool, candidates: int, classified: int,
candidates_only: int, window: dict | None, note: str | None = None) -> dict:
out = {
"provider": provider,
"classifier_configured": configured,
"candidates": candidates,
"classified": classified,
"candidates_only": candidates_only,
"window": window,
}
if note is not None:
out["note"] = note
return out

View File

@ -91,10 +91,16 @@ def test_cors_allows_vite_origin(client):
assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173"
def test_detect_degrades_gracefully(client):
data = client.post("/api/events/detect", json={}).json()
assert "note" in data and "events" in data # stubbed lane D -> 200 with note
assert client.post("/api/events/detect", json={}).status_code == 200
def test_detect_events_endpoint(client):
# Lane D (M7) has landed: POST /api/events/detect runs real detection and returns
# {result, events} (no more stub "note"). result is the run_events summary.
resp = client.post("/api/events/detect", json={})
assert resp.status_code == 200
data = resp.json()
assert "result" in data and "events" in data
assert {"candidates", "classified", "candidates_only", "classifier_configured"} <= set(data["result"])
assert isinstance(data["events"], list)
assert client.post("/api/events/detect", json={}).status_code == 200 # idempotent
def test_annotation_stored(client):

View File

@ -0,0 +1,288 @@
"""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() == []

View File

@ -18,3 +18,18 @@ Instead append an entry here and work around it locally; the integration agent a
---
<!-- entries below -->
### CR-1 — lane D — 2026-07-16 — Update obsolete stub assertion in test_api.py
- **File:** `backend/tests/test_api.py::test_detect_degrades_gracefully`
- **Problem:** That foundation test asserted the *stub* contract of `POST /api/events/detect`
(`"note" in data`), which was correct only while lane D was unimplemented. With M7 landed,
`run_events` no longer raises `NotImplementedError`, so `api.py` returns the real
`{result, events}` shape (spec M3) and the stub assertion fails.
- **Proposed change:** rename to `test_detect_events_endpoint` and assert the real landed
contract (200; `result` is the run_events summary with candidates/classified/… keys; `events`
is a list; idempotent on repeat).
- **Workaround in place:** applied the minimal edit directly (test file, not a frozen contract
file). The frozen `api.py` app is **unchanged**. Only lane D's owned modules + this obsolete
stub assertion were touched. Full suite green (89 passed).
- **Decision:** (integration) — informational; the change is a direct, unavoidable consequence
of lane D landing. Confirm the assertion matches the intended M3 detect-response shape.

59
plan/status/lane-D.md Normal file
View File

@ -0,0 +1,59 @@
# Status — lane-D
## Round 1 — 2026-07-16 — STATUS: ready_to_merge
**Adversarial review (self-run, 5 lenses → verify):** 3 findings, all confirmed & fixed —
(1) `detect_candidates` fabricated ~1 candidate/s on silent/DC/noise audio → added an AC-RMS
(std) floor `MIN_SIGNAL_STD`; silence/DC/noise now return `[]`, fixture unchanged [3,10,17];
(2) degradation test's raised tripwire was swallowed by `except Exception` → switched to a
call-recorder (`assert prepared == []`); (3) windowed test ran on a fresh DB → now seeds an
out-of-window `ai` event and asserts it survives. `pytest` **90 passed** after fixes.
**Directives acknowledged:** round 0 of plan/DIRECTIVES.md (foundation merged; lanes may start;
lane D is small + fully independent; merge when acceptance passes — no waiting on siblings).
**Acceptance checklist** (plan/lane-D-events.md + spec M7) — implementation complete, verified:
- [x] `detect_candidates` finds candidates within ±0.5 s of fixture ground-truth pulses
[3.0, 10.0, 17.0]. Evidence: `test_events_ai.py::test_detect_candidates_matches_ground_truth`
(recall+precision+count). RMS×spectral-flux, centered-10 s-window local-max, ≥ P90.
- [x] `MomentClassification` + `MomentClassifier` frozen contract preserved verbatim; import
of events_ai pulls in ZERO heavy libs/SDKs (all lazy). Evidence: import-cost probe → `[]`.
- [x] Three providers implemented against the installed SDKs (verified surfaces): Gemini
(google-genai 2.11.0, `Part.from_bytes(data,mime_type)` + `generate_content` structured
output), Claude (anthropic 0.116.0, `messages.parse(output_format=…, thinking=adaptive,
max_tokens)` → `parsed_output`, NO temperature, model `claude-opus-4-8`), Local
(openai 2.45.0, `chat.completions.create` json_object, 6 image_url parts, 1 retry).
- [x] Provider selection `FESTIVAL4D_CLASSIFIER=gemini|claude|local` (default gemini);
unconfigured → candidates-only + log line. Evidence: `test_get_classifier_*` (5 tests).
- [x] Per-candidate exception isolation. Evidence: `test_run_events_isolates_per_candidate_failure`
(2 ai + 1 fallback candidate; batch of 3 completes despite one raising).
- [x] `run_events` fills the frozen entrypoint (cli.py + api.py dispatch); writes via db helpers
only (insert-once in final state — db.py has no update-event helper; end state matches spec).
- [x] `pytest` green with NO network: **89 passed** across 5 consecutive full-suite runs.
- [x] Real-path end-to-end (ffmpeg-decoded AAC audio, not in-memory): isolated 20 s fixture →
`run_events()` degraded → candidates at 3.008/10.016/17.013 s (≤0.016 s error), exactly 3,
no spurious. Evidence: scratchpad/e2e_verify.py → RESULT PASS.
**Done this round:** implemented `backend/festival4d/events_ai.py` (candidate detection + 3
providers + get_classifier + prepare_inputs + run_events orchestration) and
`backend/tests/test_events_ai.py` (15 tests). Detection algorithm locked via a scratch
experiment before coding. Confirmed Claude call shape via claude-api skill and all three SDK
surfaces via introspection. Currently running a 5-lens adversarial review workflow
(spec-compliance / provider-SDK / detection-orchestration / contract-ownership / test-quality)
before merge.
**Coordinator note (out-of-strict-ownership edit):** updated `backend/tests/test_api.py`
`test_detect_degrades_gracefully``test_detect_events_endpoint`. That foundation test asserted
the STUB behavior (`"note" in data`) of `POST /api/events/detect`; my landing replaces the stub
with real detection returning `{result, events}`. The frozen `api.py` app is UNCHANGED — only the
now-obsolete stub assertion was updated. Flagged here for transparency; see plan/CHANGE_REQUESTS.md CR-1.
**Note on shared tree:** this working directory holds all four lanes' uncommitted work
concurrently (A/B/C/D). I will stage and commit ONLY my owned files. One transient
`test_audio_sync.py` (lane A) failure was observed once mid-run (concurrent write) but the suite
is deterministically green (89/89 ×5); not caused by lane D.
**Blockers / questions for coordinator:** none.
**Next:** address any confirmed review findings, then merge `lane/d-events` → main.