File size: 6,701 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
"""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),
    }