crowncode-backend / app /services /timeline.py
Rthur2003's picture
feat: analysis jobs queue inference xai ve timeline modules ile unit tests eklendi
413c3b6
Raw History Blame Contribute Delete
6.7 kB
"""Whole-track scan.
The opening clip gives AURIS its verdict. This module reads the rest of the
track: the first six minutes are cut into windows of about 30 s, every window
goes through the same features and the same LightGBM model, and the readings
come back as a timeline the page draws under the waveform.
A window score is the classifier applied to an excerpt shorter than the 60 s
clips it was trained on, and every training clip was either all AI or all
human. A timeline therefore shows where the music reads AI-like, not where
an AI took over.
"""
from __future__ import annotations
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Dict, List, Optional, Tuple
import numpy as np
from .feature_extractor import extract_features_array
from .inference_xai import XAIInferenceService
from .logging_config import get_logger
from .vocal_analyzer import analyze_vocals_array
logger = get_logger(__name__)
TARGET_WINDOW_SEC = 30.0
# A window quieter than this never reaches the classifier.
SILENCE_PEAK = 1e-3
PEAK_POINTS = 600
# Windows not started within this many seconds are reported as skipped.
SCAN_BUDGET_SEC = float(os.getenv("AURIS_SCAN_BUDGET_SEC", "180"))
SCAN_WORKERS = max(1, int(os.getenv("AURIS_SCAN_WORKERS", "2")))
def plan_windows(duration: float) -> List[Tuple[float, float]]:
"""Equal windows of about 30 s that tile the whole duration (20 to 45 s each)."""
if duration <= 0:
return []
count = max(1, int(round(duration / TARGET_WINDOW_SEC)))
size = duration / count
return [
(round(i * size, 3), round(duration if i == count - 1 else (i + 1) * size, 3))
for i in range(count)
]
def waveform_peaks(y: np.ndarray, points: int = PEAK_POINTS) -> List[float]:
"""Peak level per slice of the track, 0 to 1, for drawing the waveform."""
if y.size == 0:
return []
edges = np.linspace(0, y.size, min(points, y.size) + 1).astype(int)
peaks = np.array([
float(np.max(np.abs(y[a:b]))) if b > a else 0.0
for a, b in zip(edges[:-1], edges[1:])
])
top = float(peaks.max())
if top <= 0:
return [0.0] * len(peaks)
return [round(float(v), 3) for v in peaks / top]
def _read_window(
y: np.ndarray,
sr: int,
index: int,
start: float,
end: float,
xai: XAIInferenceService,
) -> Dict[str, Any]:
base: Dict[str, Any] = {"index": index, "start": round(start, 2), "end": round(end, 2)}
seg = np.ascontiguousarray(y[int(start * sr):min(y.size, int(end * sr))])
if seg.size < sr:
return {**base, "state": "skipped"}
if float(np.max(np.abs(seg))) < SILENCE_PEAK:
return {**base, "state": "silent"}
try:
features = extract_features_array(seg, sr)
vocals = None
try:
vocals = analyze_vocals_array(seg, sr)
except Exception as e: # noqa: BLE001 - the main pipeline also goes on without vocals
logger.warning(f"Window {index}: vocal analysis failed: {e}")
reading = xai.predict_window(features, vocals, window_sec=end - start)
if reading is None:
return {**base, "state": "failed"}
return {
**base,
"state": "ok",
"probability": round(reading.probability, 4),
"isAi": reading.is_ai,
"margin": reading.margin,
"hasVocals": bool(vocals and vocals.has_vocals),
"reasons": [
{
"name": c.name,
"label": c.label,
"labelEn": c.label_en,
"direction": c.direction,
"shapValue": round(c.shap_value, 4),
}
for c in reading.reasons
],
}
except Exception as e: # noqa: BLE001 - one bad window must not sink the scan
logger.warning(f"Window {index} ({start:.0f}-{end:.0f}s) failed: {e}")
return {**base, "state": "failed"}
def _summarise(segments: List[Dict[str, Any]]) -> Dict[str, Any]:
scored = [s for s in segments if s["state"] == "ok"]
seconds = sum(s["end"] - s["start"] for s in scored)
if not scored or seconds <= 0:
return {
"scoredCount": 0, "flaggedCount": 0, "scoredSec": 0.0,
"aiShare": 0.0, "meanProbability": 0.0, "maxProbability": 0.0, "peakIndex": None,
}
flagged = [s for s in scored if s["isAi"]]
peak = max(scored, key=lambda s: s["probability"])
return {
"scoredCount": len(scored),
"flaggedCount": len(flagged),
"scoredSec": round(seconds, 1),
"aiShare": round(sum(s["end"] - s["start"] for s in flagged) / seconds, 3),
"meanProbability": round(sum(s["probability"] * (s["end"] - s["start"]) for s in scored) / seconds, 4),
"maxProbability": peak["probability"],
"peakIndex": peak["index"],
}
def scan_track(
y: np.ndarray,
sr: int,
xai: XAIInferenceService,
*,
total_sec: float,
on_progress: Optional[Callable[[int, int], None]] = None,
cancelled: Optional[Callable[[], bool]] = None,
budget_sec: float = SCAN_BUDGET_SEC,
) -> Dict[str, Any]:
"""Read every window of `y` and return the timeline payload (blocking)."""
duration = y.size / sr
windows = plan_windows(duration)
started = time.monotonic()
lock = threading.Lock()
finished = 0
def work(item: Tuple[int, Tuple[float, float]]) -> Dict[str, Any]:
nonlocal finished
index, (start, end) = item
if (cancelled is not None and cancelled()) or time.monotonic() - started > budget_sec:
out: Dict[str, Any] = {"index": index, "start": round(start, 2), "end": round(end, 2), "state": "skipped"}
else:
out = _read_window(y, sr, index, start, end, xai)
with lock:
finished += 1
if on_progress is not None:
on_progress(finished, len(windows))
return out
with ThreadPoolExecutor(max_workers=min(SCAN_WORKERS, max(1, len(windows)))) as pool:
segments = list(pool.map(work, enumerate(windows)))
logger.info(
f"Timeline: {len(windows)} windows over {duration:.0f}s in {time.monotonic() - started:.1f}s, "
f"{sum(1 for s in segments if s['state'] == 'ok')} scored"
)
return {
"durationSec": round(duration, 2),
"totalSec": round(max(total_sec, duration), 2),
"truncated": total_sec - duration > 1.0,
"threshold": round(float(xai.threshold), 6),
"peaks": waveform_peaks(y),
"segments": segments,
"summary": _summarise(segments),
}