Rthur2003 commited on
Commit
413c3b6
·
1 Parent(s): e6e7656

feat: analysis jobs queue inference xai ve timeline modules ile unit tests eklendi

Browse files
app/routes/analyze.py CHANGED
@@ -50,6 +50,7 @@ from .analyze_schemas import (
50
  MetaClassifierExplanation,
51
  MetaFeatureImportance,
52
  ModelVoteModel,
 
53
  VocalAnalysis,
54
  XAIExplanation,
55
  )
@@ -70,10 +71,13 @@ from ..services.analysis_jobs import (
70
  result_cache,
71
  )
72
  from ..services.feature_extractor import (
 
73
  decode_clip,
74
  extract_features,
 
75
  AudioFeatures,
76
  )
 
77
  from ..services.vocal_analyzer import (
78
  analyze_vocals,
79
  VocalFeatures,
@@ -101,12 +105,12 @@ _EXT_MIME = {
101
  }
102
  URL_SOURCES = ("youtube", "tiktok", "instagram", "soundcloud", "twitter")
103
 
104
- # A link only needs its opening: features read 60 s, wav2vec2 30 s.
105
- YOUTUBE_CLIP_SEC = 120.0
106
  YOUTUBE_MAX_BYTES = 100 * 1024 * 1024
107
 
108
  # Bump when the pipeline's output changes, so results cached from the old one aren't served.
109
- PIPELINE_VERSION = "2"
110
 
111
  # Rate limiter for public heavy endpoints
112
  _analyze_rate_store: dict[str, list[float]] = defaultdict(list)
@@ -186,6 +190,39 @@ def _probe_audio(content: bytes) -> Tuple[Optional[int], Optional[int], Optional
186
  return None, None, None
187
 
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  def _track(future: "asyncio.Future[Any]", progress: Progress, step: str,
190
  ok: Optional[Callable[[Any], bool]] = None) -> None:
191
  """Report a parallel step as done/skipped/failed the moment it settles."""
@@ -243,6 +280,7 @@ async def _run_pipeline(
243
  logger.info(f"[{request_id}] Audio: {filename} ({file_size / 1024 / 1024:.2f}MB)")
244
  loop = asyncio.get_running_loop()
245
  fst_task: Optional["asyncio.Task[Any]"] = None
 
246
 
247
  try:
248
  async with cpu_gate.slot(ticket, progress):
@@ -276,6 +314,14 @@ async def _run_pipeline(
276
  _track(wav2vec2_future, progress, "wav2vec2", ok=lambda r: bool(r.available))
277
  # Hold the slot until every thread is done, even if one fails.
278
  await asyncio.gather(feat_future, vocal_future, clap_future, wav2vec2_future, return_exceptions=True)
 
 
 
 
 
 
 
 
279
  progress.check()
280
  except BaseException:
281
  if fst_task is not None:
@@ -549,6 +595,7 @@ async def _run_pipeline(
549
  xai=xai_payload,
550
  towerScores=tower_scores,
551
  metaClassifier=meta_payload,
 
552
  layers=layers,
553
  )
554
 
 
50
  MetaClassifierExplanation,
51
  MetaFeatureImportance,
52
  ModelVoteModel,
53
+ Timeline,
54
  VocalAnalysis,
55
  XAIExplanation,
56
  )
 
71
  result_cache,
72
  )
73
  from ..services.feature_extractor import (
74
+ MAX_TRACK_SEC,
75
  decode_clip,
76
  extract_features,
77
+ load_track,
78
  AudioFeatures,
79
  )
80
+ from ..services.timeline import scan_track
81
  from ..services.vocal_analyzer import (
82
  analyze_vocals,
83
  VocalFeatures,
 
105
  }
106
  URL_SOURCES = ("youtube", "tiktok", "instagram", "soundcloud", "twitter")
107
 
108
+ # A link needs the part the timeline scan reads; the clip layers use its first 60 s.
109
+ YOUTUBE_CLIP_SEC = MAX_TRACK_SEC
110
  YOUTUBE_MAX_BYTES = 100 * 1024 * 1024
111
 
112
  # Bump when the pipeline's output changes, so results cached from the old one aren't served.
113
+ PIPELINE_VERSION = "3"
114
 
115
  # Rate limiter for public heavy endpoints
116
  _analyze_rate_store: dict[str, list[float]] = defaultdict(list)
 
190
  return None, None, None
191
 
192
 
193
+ async def _scan_timeline(
194
+ request_id: str,
195
+ content: bytes,
196
+ total_duration: Optional[float],
197
+ progress: Progress,
198
+ loop: "asyncio.AbstractEventLoop",
199
+ ) -> Optional[Dict[str, Any]]:
200
+ """Read the whole track window by window. Never fails the analysis: on any
201
+ problem the result simply has no timeline."""
202
+ progress.set("timeline", STEP_RUNNING)
203
+ try:
204
+ y, sr = await loop.run_in_executor(_analysis_pool, load_track, content)
205
+ total = total_duration or _probe_audio(content)[2] or y.size / sr
206
+ payload = await loop.run_in_executor(
207
+ _analysis_pool,
208
+ lambda: scan_track(
209
+ y, sr, xai_service,
210
+ total_sec=total,
211
+ on_progress=lambda done, n: progress.advance("timeline", done, n),
212
+ cancelled=lambda: progress.cancelled,
213
+ ),
214
+ )
215
+ progress.set("timeline", STEP_DONE)
216
+ return payload
217
+ except ValueError as e:
218
+ logger.info(f"[{request_id}] Timeline skipped: {e}")
219
+ progress.set("timeline", STEP_SKIPPED)
220
+ except Exception as e: # noqa: BLE001 - the verdict stands without it
221
+ logger.warning(f"[{request_id}] Timeline failed: {e}", exc_info=True)
222
+ progress.set("timeline", STEP_FAILED)
223
+ return None
224
+
225
+
226
  def _track(future: "asyncio.Future[Any]", progress: Progress, step: str,
227
  ok: Optional[Callable[[Any], bool]] = None) -> None:
228
  """Report a parallel step as done/skipped/failed the moment it settles."""
 
280
  logger.info(f"[{request_id}] Audio: {filename} ({file_size / 1024 / 1024:.2f}MB)")
281
  loop = asyncio.get_running_loop()
282
  fst_task: Optional["asyncio.Task[Any]"] = None
283
+ timeline_payload: Optional[Dict[str, Any]] = None
284
 
285
  try:
286
  async with cpu_gate.slot(ticket, progress):
 
314
  _track(wav2vec2_future, progress, "wav2vec2", ok=lambda r: bool(r.available))
315
  # Hold the slot until every thread is done, even if one fails.
316
  await asyncio.gather(feat_future, vocal_future, clap_future, wav2vec2_future, return_exceptions=True)
317
+ progress.check()
318
+
319
+ # The rest of the track, window by window (same slot, same models).
320
+ scan = xai_service.available and feat_future.exception() is None
321
+ timeline_payload = await _scan_timeline(request_id, content, total_duration, progress, loop) if scan else None
322
+ if scan and timeline_payload is None:
323
+ warnings.append("timeline_unavailable")
324
+ progress.check()
325
  progress.check()
326
  except BaseException:
327
  if fst_task is not None:
 
595
  xai=xai_payload,
596
  towerScores=tower_scores,
597
  metaClassifier=meta_payload,
598
+ timeline=Timeline(**timeline_payload) if timeline_payload else None,
599
  layers=layers,
600
  )
601
 
app/services/analysis_jobs.py CHANGED
@@ -43,7 +43,7 @@ STEP_DONE = "done"
43
  STEP_FAILED = "failed"
44
  STEP_SKIPPED = "skipped"
45
 
46
- FILE_STEPS = ["features", "vocals", "wav2vec2", "clap", "fst", "xai", "meta"]
47
  URL_STEPS = ["download", *FILE_STEPS]
48
 
49
 
@@ -62,6 +62,8 @@ class Progress:
62
  self._steps: Dict[str, str] = {s: STEP_PENDING for s in steps}
63
  self._started: Dict[str, float] = {}
64
  self._took: Dict[str, float] = {}
 
 
65
  self.cancelled = False
66
  # queued: waiting for the CPU; running: downloading or analysing.
67
  self.phase = "queued"
@@ -78,6 +80,10 @@ class Progress:
78
  self._took[step] = round(now - self._started[step], 2)
79
  self._steps[step] = state
80
 
 
 
 
 
81
  def running(self, *steps: str) -> None:
82
  for s in steps:
83
  self.set(s, STEP_RUNNING)
@@ -97,6 +103,8 @@ class Progress:
97
  item: Dict[str, Any] = {"id": step, "state": state}
98
  if step in self._took:
99
  item["seconds"] = self._took[step]
 
 
100
  out.append(item)
101
  return out
102
 
 
43
  STEP_FAILED = "failed"
44
  STEP_SKIPPED = "skipped"
45
 
46
+ FILE_STEPS = ["features", "vocals", "wav2vec2", "clap", "timeline", "fst", "xai", "meta"]
47
  URL_STEPS = ["download", *FILE_STEPS]
48
 
49
 
 
62
  self._steps: Dict[str, str] = {s: STEP_PENDING for s in steps}
63
  self._started: Dict[str, float] = {}
64
  self._took: Dict[str, float] = {}
65
+ # Steps made of many parts (the timeline scan) report how far along they are.
66
+ self._fraction: Dict[str, float] = {}
67
  self.cancelled = False
68
  # queued: waiting for the CPU; running: downloading or analysing.
69
  self.phase = "queued"
 
80
  self._took[step] = round(now - self._started[step], 2)
81
  self._steps[step] = state
82
 
83
+ def advance(self, step: str, done: int, total: int) -> None:
84
+ if step in self._steps and total > 0:
85
+ self._fraction[step] = round(min(1.0, done / total), 3)
86
+
87
  def running(self, *steps: str) -> None:
88
  for s in steps:
89
  self.set(s, STEP_RUNNING)
 
103
  item: Dict[str, Any] = {"id": step, "state": state}
104
  if step in self._took:
105
  item["seconds"] = self._took[step]
106
+ if step in self._fraction and state == STEP_RUNNING:
107
+ item["progress"] = self._fraction[step]
108
  out.append(item)
109
  return out
110
 
app/services/inference_xai.py CHANGED
@@ -408,6 +408,20 @@ class XAIResult:
408
  feature_count: int = 49
409
 
410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  class XAIInferenceService:
412
  """Loads trained artifacts and performs explainable inference."""
413
 
@@ -588,6 +602,70 @@ class XAIInferenceService:
588
  feature_count=len(self.feature_cols),
589
  )
590
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
  def _build_feature_map(
592
  self,
593
  features: AudioFeatures,
 
408
  feature_count: int = 49
409
 
410
 
411
+ @dataclass
412
+ class WindowReading:
413
+ """One timeline window read by the primary classifier."""
414
+ probability: float
415
+ is_ai: bool
416
+ margin: float # 0 at the threshold, 1 at the far end of its side
417
+ reasons: List[FeatureContribution] = field(default_factory=list)
418
+
419
+
420
+ # The classifier was trained on features of 60 s clips; a window's beat count is
421
+ # scaled to that length so it lands on the same axis as the training data.
422
+ _TRAINING_CLIP_SEC = 60.0
423
+
424
+
425
  class XAIInferenceService:
426
  """Loads trained artifacts and performs explainable inference."""
427
 
 
602
  feature_count=len(self.feature_cols),
603
  )
604
 
605
+ def predict_window(
606
+ self,
607
+ features: AudioFeatures,
608
+ vocals: Optional[VocalFeatures],
609
+ *,
610
+ window_sec: float,
611
+ top: int = 3,
612
+ ) -> Optional[WindowReading]:
613
+ """Probability for one window of a longer track, with its main reasons.
614
+
615
+ Same LightGBM model, scaler and threshold as predict(); no ensemble vote
616
+ and no full SHAP table, since a timeline holds many windows.
617
+ """
618
+ if not self.available:
619
+ return None
620
+
621
+ feature_map = self._build_feature_map(features, vocals)
622
+ feature_map["beat_count"] = feature_map["beat_count"] * (_TRAINING_CLIP_SEC / max(window_sec, 1.0))
623
+ x = np.array([feature_map.get(col, 0.0) for col in self.feature_cols], dtype=np.float64)
624
+ x = np.nan_to_num(x, nan=0.0, posinf=1.0, neginf=-1.0)
625
+ x_scaled = self.scaler.transform(x.reshape(1, -1))
626
+
627
+ prob = float(self.model.predict_proba(x_scaled)[0, 1])
628
+ band = self._confidence_band(prob)
629
+
630
+ reasons: List[FeatureContribution] = []
631
+ if self.shap_explainer is not None:
632
+ try:
633
+ shap_values = self.shap_explainer.shap_values(x_scaled)
634
+ if isinstance(shap_values, list):
635
+ sv = shap_values[1][0] if len(shap_values) > 1 else shap_values[0][0]
636
+ else:
637
+ sv = shap_values[0]
638
+ ranked = sorted(range(len(self.feature_cols)), key=lambda i: abs(float(sv[i])), reverse=True)
639
+ for i in ranked[:top]:
640
+ col = self.feature_cols[i]
641
+ shap_v = float(sv[i])
642
+ if abs(shap_v) < 0.001:
643
+ continue
644
+ raw = feature_map.get(col, 0.0)
645
+ stats = self.feature_stats.get(col, {})
646
+ std = stats.get("std", 1.0) or 1.0
647
+ meta = FEATURE_CATALOG.get(col, {})
648
+ reasons.append(FeatureContribution(
649
+ name=col,
650
+ label=meta.get("label", col),
651
+ label_en=meta.get("labelEn", col),
652
+ category=meta.get("category", "other"),
653
+ value=float(raw),
654
+ z_score=float((raw - stats.get("mean", 0.0)) / std),
655
+ shap_value=shap_v,
656
+ direction="towards_ai" if shap_v > 0 else "towards_human",
657
+ description=meta.get("description", ""),
658
+ ))
659
+ except Exception as e: # noqa: BLE001 - a missing explanation must not drop the window
660
+ logger.warning(f"Window SHAP failed: {e}")
661
+
662
+ return WindowReading(
663
+ probability=prob,
664
+ is_ai=prob >= self.threshold,
665
+ margin=band.margin,
666
+ reasons=reasons,
667
+ )
668
+
669
  def _build_feature_map(
670
  self,
671
  features: AudioFeatures,
app/services/timeline.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Whole-track scan.
2
+
3
+ The opening clip gives AURIS its verdict. This module reads the rest of the
4
+ track: the first six minutes are cut into windows of about 30 s, every window
5
+ goes through the same features and the same LightGBM model, and the readings
6
+ come back as a timeline the page draws under the waveform.
7
+
8
+ A window score is the classifier applied to an excerpt shorter than the 60 s
9
+ clips it was trained on, and every training clip was either all AI or all
10
+ human. A timeline therefore shows where the music reads AI-like, not where
11
+ an AI took over.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import threading
18
+ import time
19
+ from concurrent.futures import ThreadPoolExecutor
20
+ from typing import Any, Callable, Dict, List, Optional, Tuple
21
+
22
+ import numpy as np
23
+
24
+ from .feature_extractor import extract_features_array
25
+ from .inference_xai import XAIInferenceService
26
+ from .logging_config import get_logger
27
+ from .vocal_analyzer import analyze_vocals_array
28
+
29
+ logger = get_logger(__name__)
30
+
31
+ TARGET_WINDOW_SEC = 30.0
32
+ # A window quieter than this never reaches the classifier.
33
+ SILENCE_PEAK = 1e-3
34
+ PEAK_POINTS = 600
35
+ # Windows not started within this many seconds are reported as skipped.
36
+ SCAN_BUDGET_SEC = float(os.getenv("AURIS_SCAN_BUDGET_SEC", "180"))
37
+ SCAN_WORKERS = max(1, int(os.getenv("AURIS_SCAN_WORKERS", "2")))
38
+
39
+
40
+ def plan_windows(duration: float) -> List[Tuple[float, float]]:
41
+ """Equal windows of about 30 s that tile the whole duration (20 to 45 s each)."""
42
+ if duration <= 0:
43
+ return []
44
+ count = max(1, int(round(duration / TARGET_WINDOW_SEC)))
45
+ size = duration / count
46
+ return [
47
+ (round(i * size, 3), round(duration if i == count - 1 else (i + 1) * size, 3))
48
+ for i in range(count)
49
+ ]
50
+
51
+
52
+ def waveform_peaks(y: np.ndarray, points: int = PEAK_POINTS) -> List[float]:
53
+ """Peak level per slice of the track, 0 to 1, for drawing the waveform."""
54
+ if y.size == 0:
55
+ return []
56
+ edges = np.linspace(0, y.size, min(points, y.size) + 1).astype(int)
57
+ peaks = np.array([
58
+ float(np.max(np.abs(y[a:b]))) if b > a else 0.0
59
+ for a, b in zip(edges[:-1], edges[1:])
60
+ ])
61
+ top = float(peaks.max())
62
+ if top <= 0:
63
+ return [0.0] * len(peaks)
64
+ return [round(float(v), 3) for v in peaks / top]
65
+
66
+
67
+ def _read_window(
68
+ y: np.ndarray,
69
+ sr: int,
70
+ index: int,
71
+ start: float,
72
+ end: float,
73
+ xai: XAIInferenceService,
74
+ ) -> Dict[str, Any]:
75
+ base: Dict[str, Any] = {"index": index, "start": round(start, 2), "end": round(end, 2)}
76
+ seg = np.ascontiguousarray(y[int(start * sr):min(y.size, int(end * sr))])
77
+ if seg.size < sr:
78
+ return {**base, "state": "skipped"}
79
+ if float(np.max(np.abs(seg))) < SILENCE_PEAK:
80
+ return {**base, "state": "silent"}
81
+ try:
82
+ features = extract_features_array(seg, sr)
83
+ vocals = None
84
+ try:
85
+ vocals = analyze_vocals_array(seg, sr)
86
+ except Exception as e: # noqa: BLE001 - the main pipeline also goes on without vocals
87
+ logger.warning(f"Window {index}: vocal analysis failed: {e}")
88
+ reading = xai.predict_window(features, vocals, window_sec=end - start)
89
+ if reading is None:
90
+ return {**base, "state": "failed"}
91
+ return {
92
+ **base,
93
+ "state": "ok",
94
+ "probability": round(reading.probability, 4),
95
+ "isAi": reading.is_ai,
96
+ "margin": reading.margin,
97
+ "hasVocals": bool(vocals and vocals.has_vocals),
98
+ "reasons": [
99
+ {
100
+ "name": c.name,
101
+ "label": c.label,
102
+ "labelEn": c.label_en,
103
+ "direction": c.direction,
104
+ "shapValue": round(c.shap_value, 4),
105
+ }
106
+ for c in reading.reasons
107
+ ],
108
+ }
109
+ except Exception as e: # noqa: BLE001 - one bad window must not sink the scan
110
+ logger.warning(f"Window {index} ({start:.0f}-{end:.0f}s) failed: {e}")
111
+ return {**base, "state": "failed"}
112
+
113
+
114
+ def _summarise(segments: List[Dict[str, Any]]) -> Dict[str, Any]:
115
+ scored = [s for s in segments if s["state"] == "ok"]
116
+ seconds = sum(s["end"] - s["start"] for s in scored)
117
+ if not scored or seconds <= 0:
118
+ return {
119
+ "scoredCount": 0, "flaggedCount": 0, "scoredSec": 0.0,
120
+ "aiShare": 0.0, "meanProbability": 0.0, "maxProbability": 0.0, "peakIndex": None,
121
+ }
122
+ flagged = [s for s in scored if s["isAi"]]
123
+ peak = max(scored, key=lambda s: s["probability"])
124
+ return {
125
+ "scoredCount": len(scored),
126
+ "flaggedCount": len(flagged),
127
+ "scoredSec": round(seconds, 1),
128
+ "aiShare": round(sum(s["end"] - s["start"] for s in flagged) / seconds, 3),
129
+ "meanProbability": round(sum(s["probability"] * (s["end"] - s["start"]) for s in scored) / seconds, 4),
130
+ "maxProbability": peak["probability"],
131
+ "peakIndex": peak["index"],
132
+ }
133
+
134
+
135
+ def scan_track(
136
+ y: np.ndarray,
137
+ sr: int,
138
+ xai: XAIInferenceService,
139
+ *,
140
+ total_sec: float,
141
+ on_progress: Optional[Callable[[int, int], None]] = None,
142
+ cancelled: Optional[Callable[[], bool]] = None,
143
+ budget_sec: float = SCAN_BUDGET_SEC,
144
+ ) -> Dict[str, Any]:
145
+ """Read every window of `y` and return the timeline payload (blocking)."""
146
+ duration = y.size / sr
147
+ windows = plan_windows(duration)
148
+ started = time.monotonic()
149
+ lock = threading.Lock()
150
+ finished = 0
151
+
152
+ def work(item: Tuple[int, Tuple[float, float]]) -> Dict[str, Any]:
153
+ nonlocal finished
154
+ index, (start, end) = item
155
+ if (cancelled is not None and cancelled()) or time.monotonic() - started > budget_sec:
156
+ out: Dict[str, Any] = {"index": index, "start": round(start, 2), "end": round(end, 2), "state": "skipped"}
157
+ else:
158
+ out = _read_window(y, sr, index, start, end, xai)
159
+ with lock:
160
+ finished += 1
161
+ if on_progress is not None:
162
+ on_progress(finished, len(windows))
163
+ return out
164
+
165
+ with ThreadPoolExecutor(max_workers=min(SCAN_WORKERS, max(1, len(windows)))) as pool:
166
+ segments = list(pool.map(work, enumerate(windows)))
167
+
168
+ logger.info(
169
+ f"Timeline: {len(windows)} windows over {duration:.0f}s in {time.monotonic() - started:.1f}s, "
170
+ f"{sum(1 for s in segments if s['state'] == 'ok')} scored"
171
+ )
172
+ return {
173
+ "durationSec": round(duration, 2),
174
+ "totalSec": round(max(total_sec, duration), 2),
175
+ "truncated": total_sec - duration > 1.0,
176
+ "threshold": round(float(xai.threshold), 6),
177
+ "peaks": waveform_peaks(y),
178
+ "segments": segments,
179
+ "summary": _summarise(segments),
180
+ }
tests/test_timeline.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Whole-track scan: window plan, waveform peaks and the scan loop.
2
+
3
+ The classifier is a stub here; only the audio maths and the bookkeeping run.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import dataclasses
9
+ from typing import List, Optional
10
+
11
+ import numpy as np
12
+ import pytest
13
+
14
+ from app.services import timeline
15
+ from app.services.feature_extractor import AudioFeatures
16
+ from app.services.inference_xai import FeatureContribution, WindowReading, XAIInferenceService
17
+
18
+ SR = 22050
19
+
20
+
21
+ class StubXai:
22
+ """Scores a window by how loud it is, so each test can steer the result."""
23
+
24
+ threshold = 0.43
25
+
26
+ def __init__(self, probabilities: Optional[List[float]] = None) -> None:
27
+ self._queue = list(probabilities or [])
28
+
29
+ def predict_window(self, features, vocals, *, window_sec: float, top: int = 3) -> WindowReading:
30
+ p = self._queue.pop(0) if self._queue else 0.8
31
+ reason = FeatureContribution(
32
+ name="spectral_flatness_mean", label="Spektral düzlük", label_en="Spectral flatness",
33
+ category="spectral", value=0.1, z_score=-1.2, shap_value=0.4 if p >= self.threshold else -0.4,
34
+ direction="towards_ai" if p >= self.threshold else "towards_human", description="",
35
+ )
36
+ return WindowReading(probability=p, is_ai=p >= self.threshold, margin=0.5, reasons=[reason])
37
+
38
+
39
+ @pytest.fixture
40
+ def fast_features(monkeypatch):
41
+ """Skip the librosa work: the scan loop is what these tests cover."""
42
+ monkeypatch.setattr(timeline, "extract_features_array", lambda seg, sr: object())
43
+ monkeypatch.setattr(timeline, "analyze_vocals_array", lambda seg, sr: None)
44
+
45
+
46
+ def tone(seconds: float, level: float = 0.5) -> np.ndarray:
47
+ t = np.arange(int(seconds * SR)) / SR
48
+ return (level * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
49
+
50
+
51
+ # ── plan ─────────────────────────────────────────────────────────────
52
+
53
+ @pytest.mark.parametrize("duration", [3.0, 29.0, 44.9, 45.0, 75.0, 214.2, 359.9])
54
+ def test_windows_tile_the_whole_track(duration: float) -> None:
55
+ windows = timeline.plan_windows(duration)
56
+ assert windows[0][0] == 0
57
+ assert windows[-1][1] == pytest.approx(duration, abs=0.01)
58
+ for (_, end), (start, _) in zip(windows, windows[1:]):
59
+ assert end == pytest.approx(start, abs=0.01)
60
+
61
+
62
+ @pytest.mark.parametrize("duration", [45.0, 75.0, 214.2, 359.9])
63
+ def test_window_length_stays_near_thirty_seconds(duration: float) -> None:
64
+ for start, end in timeline.plan_windows(duration):
65
+ assert 20.0 <= end - start <= 45.0
66
+
67
+
68
+ def test_nothing_to_plan_for_empty_audio() -> None:
69
+ assert timeline.plan_windows(0) == []
70
+
71
+
72
+ # ── peaks ────────────────────────────────────────────────────────────
73
+
74
+ def test_peaks_are_normalised_and_bounded() -> None:
75
+ peaks = timeline.waveform_peaks(tone(10.0, 0.2), points=100)
76
+ assert len(peaks) == 100
77
+ assert max(peaks) == 1.0
78
+ assert min(peaks) >= 0.0
79
+
80
+
81
+ def test_peaks_of_silence_are_zero() -> None:
82
+ assert set(timeline.waveform_peaks(np.zeros(SR, dtype=np.float32), points=10)) == {0.0}
83
+
84
+
85
+ def test_peaks_of_a_very_short_clip_do_not_overrun() -> None:
86
+ assert len(timeline.waveform_peaks(np.ones(5, dtype=np.float32), points=600)) == 5
87
+
88
+
89
+ # ── scan ─────────────────────────────────────────────────────────────
90
+
91
+ def test_scan_scores_every_window_and_summarises(fast_features) -> None:
92
+ y = tone(90.0) # three windows of 30 s
93
+ out = timeline.scan_track(y, SR, StubXai([0.9, 0.1, 0.7]), total_sec=90.0)
94
+
95
+ assert [s["state"] for s in out["segments"]] == ["ok", "ok", "ok"]
96
+ assert [s["isAi"] for s in out["segments"]] == [True, False, True]
97
+ summary = out["summary"]
98
+ assert summary["scoredCount"] == 3
99
+ assert summary["flaggedCount"] == 2
100
+ assert summary["aiShare"] == pytest.approx(2 / 3, abs=0.01)
101
+ assert summary["peakIndex"] == 0
102
+ assert summary["maxProbability"] == 0.9
103
+ assert out["truncated"] is False
104
+ assert out["threshold"] == pytest.approx(0.43)
105
+ assert len(out["peaks"]) > 0
106
+ assert out["segments"][0]["reasons"][0]["direction"] == "towards_ai"
107
+
108
+
109
+ def test_scan_keeps_window_order_with_parallel_workers(fast_features, monkeypatch) -> None:
110
+ monkeypatch.setattr(timeline, "SCAN_WORKERS", 4)
111
+ out = timeline.scan_track(tone(300.0), SR, StubXai(), total_sec=300.0)
112
+ assert [s["index"] for s in out["segments"]] == list(range(10))
113
+ starts = [s["start"] for s in out["segments"]]
114
+ assert starts == sorted(starts)
115
+
116
+
117
+ def test_a_silent_window_is_not_scored(fast_features) -> None:
118
+ y = np.concatenate([tone(30.0), np.zeros(30 * SR, dtype=np.float32)])
119
+ out = timeline.scan_track(y, SR, StubXai([0.8]), total_sec=60.0)
120
+ assert [s["state"] for s in out["segments"]] == ["ok", "silent"]
121
+ assert out["summary"]["scoredCount"] == 1
122
+ assert out["summary"]["scoredSec"] == pytest.approx(30.0, abs=0.1)
123
+
124
+
125
+ def test_windows_past_the_budget_are_skipped(fast_features) -> None:
126
+ out = timeline.scan_track(tone(90.0), SR, StubXai(), total_sec=90.0, budget_sec=-1.0)
127
+ assert {s["state"] for s in out["segments"]} == {"skipped"}
128
+ assert out["summary"]["scoredCount"] == 0
129
+ assert out["summary"]["peakIndex"] is None
130
+
131
+
132
+ def test_cancelling_skips_the_remaining_windows(fast_features) -> None:
133
+ out = timeline.scan_track(tone(90.0), SR, StubXai(), total_sec=90.0, cancelled=lambda: True)
134
+ assert {s["state"] for s in out["segments"]} == {"skipped"}
135
+
136
+
137
+ def test_a_long_track_is_flagged_as_truncated(fast_features) -> None:
138
+ out = timeline.scan_track(tone(30.0), SR, StubXai(), total_sec=500.0)
139
+ assert out["truncated"] is True
140
+ assert out["totalSec"] == 500.0
141
+ assert out["durationSec"] == pytest.approx(30.0, abs=0.05)
142
+
143
+
144
+ def test_a_failing_window_does_not_sink_the_scan(fast_features, monkeypatch) -> None:
145
+ calls = {"n": 0}
146
+
147
+ def flaky(seg, sr):
148
+ calls["n"] += 1
149
+ if calls["n"] == 2:
150
+ raise RuntimeError("boom")
151
+ return object()
152
+
153
+ monkeypatch.setattr(timeline, "extract_features_array", flaky)
154
+ monkeypatch.setattr(timeline, "SCAN_WORKERS", 1)
155
+ out = timeline.scan_track(tone(90.0), SR, StubXai(), total_sec=90.0)
156
+ assert [s["state"] for s in out["segments"]] == ["ok", "failed", "ok"]
157
+ assert out["summary"]["scoredCount"] == 2
158
+
159
+
160
+ def test_progress_reports_every_window(fast_features) -> None:
161
+ seen: List[tuple] = []
162
+ timeline.scan_track(tone(90.0), SR, StubXai(), total_sec=90.0, on_progress=lambda d, n: seen.append((d, n)))
163
+ assert sorted(seen) == [(1, 3), (2, 3), (3, 3)]
164
+
165
+
166
+ def test_real_audio_features_run_on_a_short_window() -> None:
167
+ """The librosa path, with only the classifier stubbed."""
168
+ rng = np.random.default_rng(3)
169
+ y = (tone(4.0, 0.3) + 0.02 * rng.standard_normal(4 * SR)).astype(np.float32)
170
+ out = timeline.scan_track(y, SR, StubXai([0.2]), total_sec=4.0)
171
+ assert out["segments"][0]["state"] == "ok"
172
+ assert out["segments"][0]["probability"] == 0.2
173
+
174
+
175
+ def test_window_beat_count_is_scaled_to_the_training_clip() -> None:
176
+ """A 30 s window has about half the beats of a 60 s clip; the model expects 60 s."""
177
+ seen = {}
178
+
179
+ class Scaler:
180
+ def transform(self, x):
181
+ seen["x"] = x.copy()
182
+ return x
183
+
184
+ class Model:
185
+ def predict_proba(self, x):
186
+ return np.array([[0.4, 0.6]])
187
+
188
+ svc = XAIInferenceService.__new__(XAIInferenceService)
189
+ svc.available = True
190
+ svc.threshold = 0.43
191
+ svc.feature_cols = ["beat_count", "tempo_bpm"]
192
+ svc.feature_stats = {}
193
+ svc.shap_explainer = None
194
+ svc.scaler = Scaler()
195
+ svc.model = Model()
196
+
197
+ features = AudioFeatures(**{f.name: 0 for f in dataclasses.fields(AudioFeatures)})
198
+ features.beat_count = 25
199
+ features.tempo_bpm = 120.0
200
+
201
+ reading = svc.predict_window(features, None, window_sec=30.0)
202
+
203
+ assert seen["x"][0, 0] == 50.0
204
+ assert seen["x"][0, 1] == 120.0
205
+ assert reading is not None
206
+ assert reading.probability == pytest.approx(0.6)
207
+ assert reading.is_ai is True
208
+ assert reading.reasons == []