Spaces:
Sleeping
Sleeping
feat: analysis jobs queue inference xai ve timeline modules ile unit tests eklendi
Browse files- app/routes/analyze.py +50 -3
- app/services/analysis_jobs.py +9 -1
- app/services/inference_xai.py +78 -0
- app/services/timeline.py +180 -0
- tests/test_timeline.py +208 -0
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
|
| 105 |
-
YOUTUBE_CLIP_SEC =
|
| 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 = "
|
| 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 == []
|