Spaces:
Sleeping
Sleeping
File size: 8,320 Bytes
413c3b6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | """Whole-track scan: window plan, waveform peaks and the scan loop.
The classifier is a stub here; only the audio maths and the bookkeeping run.
"""
from __future__ import annotations
import dataclasses
from typing import List, Optional
import numpy as np
import pytest
from app.services import timeline
from app.services.feature_extractor import AudioFeatures
from app.services.inference_xai import FeatureContribution, WindowReading, XAIInferenceService
SR = 22050
class StubXai:
"""Scores a window by how loud it is, so each test can steer the result."""
threshold = 0.43
def __init__(self, probabilities: Optional[List[float]] = None) -> None:
self._queue = list(probabilities or [])
def predict_window(self, features, vocals, *, window_sec: float, top: int = 3) -> WindowReading:
p = self._queue.pop(0) if self._queue else 0.8
reason = FeatureContribution(
name="spectral_flatness_mean", label="Spektral düzlük", label_en="Spectral flatness",
category="spectral", value=0.1, z_score=-1.2, shap_value=0.4 if p >= self.threshold else -0.4,
direction="towards_ai" if p >= self.threshold else "towards_human", description="",
)
return WindowReading(probability=p, is_ai=p >= self.threshold, margin=0.5, reasons=[reason])
@pytest.fixture
def fast_features(monkeypatch):
"""Skip the librosa work: the scan loop is what these tests cover."""
monkeypatch.setattr(timeline, "extract_features_array", lambda seg, sr: object())
monkeypatch.setattr(timeline, "analyze_vocals_array", lambda seg, sr: None)
def tone(seconds: float, level: float = 0.5) -> np.ndarray:
t = np.arange(int(seconds * SR)) / SR
return (level * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
# ── plan ─────────────────────────────────────────────────────────────
@pytest.mark.parametrize("duration", [3.0, 29.0, 44.9, 45.0, 75.0, 214.2, 359.9])
def test_windows_tile_the_whole_track(duration: float) -> None:
windows = timeline.plan_windows(duration)
assert windows[0][0] == 0
assert windows[-1][1] == pytest.approx(duration, abs=0.01)
for (_, end), (start, _) in zip(windows, windows[1:]):
assert end == pytest.approx(start, abs=0.01)
@pytest.mark.parametrize("duration", [45.0, 75.0, 214.2, 359.9])
def test_window_length_stays_near_thirty_seconds(duration: float) -> None:
for start, end in timeline.plan_windows(duration):
assert 20.0 <= end - start <= 45.0
def test_nothing_to_plan_for_empty_audio() -> None:
assert timeline.plan_windows(0) == []
# ── peaks ────────────────────────────────────────────────────────────
def test_peaks_are_normalised_and_bounded() -> None:
peaks = timeline.waveform_peaks(tone(10.0, 0.2), points=100)
assert len(peaks) == 100
assert max(peaks) == 1.0
assert min(peaks) >= 0.0
def test_peaks_of_silence_are_zero() -> None:
assert set(timeline.waveform_peaks(np.zeros(SR, dtype=np.float32), points=10)) == {0.0}
def test_peaks_of_a_very_short_clip_do_not_overrun() -> None:
assert len(timeline.waveform_peaks(np.ones(5, dtype=np.float32), points=600)) == 5
# ── scan ─────────────────────────────────────────────────────────────
def test_scan_scores_every_window_and_summarises(fast_features) -> None:
y = tone(90.0) # three windows of 30 s
out = timeline.scan_track(y, SR, StubXai([0.9, 0.1, 0.7]), total_sec=90.0)
assert [s["state"] for s in out["segments"]] == ["ok", "ok", "ok"]
assert [s["isAi"] for s in out["segments"]] == [True, False, True]
summary = out["summary"]
assert summary["scoredCount"] == 3
assert summary["flaggedCount"] == 2
assert summary["aiShare"] == pytest.approx(2 / 3, abs=0.01)
assert summary["peakIndex"] == 0
assert summary["maxProbability"] == 0.9
assert out["truncated"] is False
assert out["threshold"] == pytest.approx(0.43)
assert len(out["peaks"]) > 0
assert out["segments"][0]["reasons"][0]["direction"] == "towards_ai"
def test_scan_keeps_window_order_with_parallel_workers(fast_features, monkeypatch) -> None:
monkeypatch.setattr(timeline, "SCAN_WORKERS", 4)
out = timeline.scan_track(tone(300.0), SR, StubXai(), total_sec=300.0)
assert [s["index"] for s in out["segments"]] == list(range(10))
starts = [s["start"] for s in out["segments"]]
assert starts == sorted(starts)
def test_a_silent_window_is_not_scored(fast_features) -> None:
y = np.concatenate([tone(30.0), np.zeros(30 * SR, dtype=np.float32)])
out = timeline.scan_track(y, SR, StubXai([0.8]), total_sec=60.0)
assert [s["state"] for s in out["segments"]] == ["ok", "silent"]
assert out["summary"]["scoredCount"] == 1
assert out["summary"]["scoredSec"] == pytest.approx(30.0, abs=0.1)
def test_windows_past_the_budget_are_skipped(fast_features) -> None:
out = timeline.scan_track(tone(90.0), SR, StubXai(), total_sec=90.0, budget_sec=-1.0)
assert {s["state"] for s in out["segments"]} == {"skipped"}
assert out["summary"]["scoredCount"] == 0
assert out["summary"]["peakIndex"] is None
def test_cancelling_skips_the_remaining_windows(fast_features) -> None:
out = timeline.scan_track(tone(90.0), SR, StubXai(), total_sec=90.0, cancelled=lambda: True)
assert {s["state"] for s in out["segments"]} == {"skipped"}
def test_a_long_track_is_flagged_as_truncated(fast_features) -> None:
out = timeline.scan_track(tone(30.0), SR, StubXai(), total_sec=500.0)
assert out["truncated"] is True
assert out["totalSec"] == 500.0
assert out["durationSec"] == pytest.approx(30.0, abs=0.05)
def test_a_failing_window_does_not_sink_the_scan(fast_features, monkeypatch) -> None:
calls = {"n": 0}
def flaky(seg, sr):
calls["n"] += 1
if calls["n"] == 2:
raise RuntimeError("boom")
return object()
monkeypatch.setattr(timeline, "extract_features_array", flaky)
monkeypatch.setattr(timeline, "SCAN_WORKERS", 1)
out = timeline.scan_track(tone(90.0), SR, StubXai(), total_sec=90.0)
assert [s["state"] for s in out["segments"]] == ["ok", "failed", "ok"]
assert out["summary"]["scoredCount"] == 2
def test_progress_reports_every_window(fast_features) -> None:
seen: List[tuple] = []
timeline.scan_track(tone(90.0), SR, StubXai(), total_sec=90.0, on_progress=lambda d, n: seen.append((d, n)))
assert sorted(seen) == [(1, 3), (2, 3), (3, 3)]
def test_real_audio_features_run_on_a_short_window() -> None:
"""The librosa path, with only the classifier stubbed."""
rng = np.random.default_rng(3)
y = (tone(4.0, 0.3) + 0.02 * rng.standard_normal(4 * SR)).astype(np.float32)
out = timeline.scan_track(y, SR, StubXai([0.2]), total_sec=4.0)
assert out["segments"][0]["state"] == "ok"
assert out["segments"][0]["probability"] == 0.2
def test_window_beat_count_is_scaled_to_the_training_clip() -> None:
"""A 30 s window has about half the beats of a 60 s clip; the model expects 60 s."""
seen = {}
class Scaler:
def transform(self, x):
seen["x"] = x.copy()
return x
class Model:
def predict_proba(self, x):
return np.array([[0.4, 0.6]])
svc = XAIInferenceService.__new__(XAIInferenceService)
svc.available = True
svc.threshold = 0.43
svc.feature_cols = ["beat_count", "tempo_bpm"]
svc.feature_stats = {}
svc.shap_explainer = None
svc.scaler = Scaler()
svc.model = Model()
features = AudioFeatures(**{f.name: 0 for f in dataclasses.fields(AudioFeatures)})
features.beat_count = 25
features.tempo_bpm = 120.0
reading = svc.predict_window(features, None, window_sec=30.0)
assert seen["x"][0, 0] == 50.0
assert seen["x"][0, 1] == 120.0
assert reading is not None
assert reading.probability == pytest.approx(0.6)
assert reading.is_ai is True
assert reading.reasons == []
|