Rthur2003 commited on
Commit
06b738c
·
1 Parent(s): 379d1f6

feat: aI analysis servisleri routes detectors ve test suites eklendi

Browse files
Dockerfile CHANGED
@@ -40,15 +40,9 @@ RUN pip install --no-cache-dir torch torchvision torchaudio --index-url https://
40
  && pip install --no-cache-dir -r requirements.txt \
41
  && pip install --no-cache-dir "huggingface-hub>=0.20.0"
42
 
43
- # Uygulama kodu
44
- COPY app ./app
45
- COPY startup.py .
46
-
47
- # Models dizini — startup.py çalışınca doldurulacak
48
- RUN mkdir -p /app/models
49
-
50
  # Hugging Face Spaces için non-root kullanıcı (güvenlik)
51
  RUN useradd -m -u 1000 user \
 
52
  && chown -R user:user /app
53
  USER user
54
 
@@ -57,12 +51,22 @@ ENV HOME=/home/user \
57
  AURIS_MODELS_REPO=Rthur2003/auris-models \
58
  MODELS_DIR=/app/models
59
 
 
 
 
 
 
 
 
 
 
 
60
  # Hugging Face Spaces varsayılan port: 7860
61
  EXPOSE 7860
62
 
63
- # Health check — model download ~2-3 dk sürebilir, start-period uzun
64
- HEALTHCHECK --interval=30s --timeout=10s --start-period=180s --retries=5 \
65
  CMD curl -f http://localhost:7860/api/health || exit 1
66
 
67
- # Başlat: önce modelleri indir, sonra API'yi ayağa kaldır
68
  CMD ["sh", "-c", "python startup.py && uvicorn app.main:app --host 0.0.0.0 --port 7860"]
 
40
  && pip install --no-cache-dir -r requirements.txt \
41
  && pip install --no-cache-dir "huggingface-hub>=0.20.0"
42
 
 
 
 
 
 
 
 
43
  # Hugging Face Spaces için non-root kullanıcı (güvenlik)
44
  RUN useradd -m -u 1000 user \
45
+ && mkdir -p /app/models \
46
  && chown -R user:user /app
47
  USER user
48
 
 
51
  AURIS_MODELS_REPO=Rthur2003/auris-models \
52
  MODELS_DIR=/app/models
53
 
54
+ # Model ağırlıkları imaja gömülür: Space uykudan her uyandığında ~415 MB
55
+ # AURIS modelini ve wav2vec2-base'i yeniden indirmek yerine hazır bulur.
56
+ # Kod değişince bu katmanlar önbellekten gelir (yalnızca startup.py değişirse yeniden iner).
57
+ COPY --chown=user:user startup.py .
58
+ RUN python startup.py \
59
+ && python -c "from transformers import Wav2Vec2Model; Wav2Vec2Model.from_pretrained('facebook/wav2vec2-base')"
60
+
61
+ # Uygulama kodu
62
+ COPY --chown=user:user app ./app
63
+
64
  # Hugging Face Spaces varsayılan port: 7860
65
  EXPOSE 7860
66
 
67
+ # Health check — modeller imajda, açılış kısa
68
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=5 \
69
  CMD curl -f http://localhost:7860/api/health || exit 1
70
 
71
+ # startup.py dosyalar mevcutsa hiçbir şey indirmez; eksik kalan olursa tamamlar.
72
  CMD ["sh", "-c", "python startup.py && uvicorn app.main:app --host 0.0.0.0 --port 7860"]
app/routes/analyze.py CHANGED
@@ -1,29 +1,41 @@
1
  """General analysis route for CrownCode.
2
 
3
- Handles YouTube URLs and file uploads through a unified endpoint.
4
  Response format matches platform/hooks/analysisTypes.ts
5
 
6
  Pipeline:
7
  1. Source acquisition (YouTube download or file upload)
8
- 2. Feature extraction (spectral, temporal, harmonic)
9
- 3. Vocal analysis (pitch, vibrato, formant, breath)
10
- 4. Score fusion (local features + vocals + external services)
11
- 5. Response formatting (platform-compatible JSON)
 
 
 
 
 
 
 
 
12
  """
13
 
14
  from __future__ import annotations
15
 
16
  import asyncio
17
  import io
 
18
  import time
19
  import uuid
20
  from collections import defaultdict
21
  from concurrent.futures import ThreadPoolExecutor
22
- from typing import Optional, List, Dict
 
 
23
  from fastapi import (
24
  APIRouter, File, UploadFile, Form,
25
  Request, Depends, HTTPException, status,
26
  )
 
27
 
28
  from .analyze_schemas import (
29
  AnalysisFeatures,
@@ -38,8 +50,16 @@ from .analyze_schemas import (
38
  VocalAnalysis,
39
  XAIExplanation,
40
  )
41
- from ..services.youtube_analysis import YouTubeAnalysisService
42
- from ..services.preview_model import PreviewModel
 
 
 
 
 
 
 
 
43
  from ..services.feature_extractor import (
44
  extract_features,
45
  AudioFeatures,
@@ -54,11 +74,30 @@ from ..services.fst_client import FSTClientService
54
  from ..services.wav2vec2_detector import Wav2Vec2DetectorService
55
  from ..services.inference_xai import get_xai_service
56
  from ..services.meta_classifier import MetaClassifierService, MODEL_VERSION_TRAINED
 
 
57
  from ..services.logging_config import get_logger
58
 
59
  router = APIRouter()
60
  logger = get_logger(__name__)
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  # Rate limiter for public heavy endpoints
63
  _analyze_rate_store: dict[str, list[float]] = defaultdict(list)
64
  _ANALYZE_RATE_WINDOW = 60 # seconds
@@ -66,9 +105,23 @@ _ANALYZE_RATE_MAX = 20 # requests per minute per IP
66
  _ANALYZE_MAX_IPS = 10_000
67
 
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  async def _analyze_rate_limit(request: Request) -> None:
70
  """Rate limit for analysis endpoints."""
71
- client_ip = request.client.host if request.client else "unknown"
72
  now = time.time()
73
  cutoff = now - _ANALYZE_RATE_WINDOW
74
 
@@ -93,8 +146,6 @@ async def _analyze_rate_limit(request: Request) -> None:
93
  _analyze_rate_store[client_ip] = hits
94
 
95
 
96
- youtube_service = YouTubeAnalysisService()
97
- preview_model = PreviewModel()
98
  clap_service = CLAPDetectorService()
99
  fst_service = FSTClientService()
100
  wav2vec2_service = Wav2Vec2DetectorService()
@@ -103,167 +154,92 @@ meta_classifier_service = MetaClassifierService()
103
 
104
  # Thread pool for CPU-bound audio analysis (feature extraction + vocal)
105
  _analysis_pool = ThreadPoolExecutor(max_workers=3)
 
 
106
 
107
 
108
- @router.post(
109
- "/api/analyze",
110
- response_model=AnalyzeResponse,
111
- dependencies=[Depends(_analyze_rate_limit)],
112
- )
113
- async def analyze(
114
- sourceType: str = Form(...),
115
- url: Optional[str] = Form(None),
116
- file: Optional[UploadFile] = File(None)
117
- ) -> AnalyzeResponse:
118
- """Unified analysis endpoint."""
119
- request_id = str(uuid.uuid4())[:8]
120
- start_time = time.monotonic()
121
- logger.info(f"[{request_id}] Analysis: sourceType={sourceType}")
122
-
123
- try:
124
- if sourceType in ("youtube", "tiktok", "instagram", "soundcloud", "twitter"):
125
- if not url:
126
- return AnalyzeResponse(errors=["missing_url"])
127
- return await _analyze_youtube(request_id, url, start_time)
128
 
129
- elif sourceType == "file":
130
- if not file:
131
- return AnalyzeResponse(errors=["missing_file"])
132
- return await _analyze_file(request_id, file, start_time)
133
 
134
- elif sourceType in ["spotify", "apple"]:
135
- return AnalyzeResponse(errors=["unsupported_source"])
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  else:
138
- return AnalyzeResponse(errors=["invalid_source_type"])
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
- except Exception as e:
141
- logger.error(f"[{request_id}] Error: {e}", exc_info=True)
142
- return AnalyzeResponse(errors=["internal_error"])
 
 
 
143
 
144
 
145
- async def _analyze_youtube(
146
- request_id: str, url: str, start_time: float
147
- ) -> AnalyzeResponse:
148
- """Analyze YouTube URL with full pipeline."""
149
- try:
150
- yt_result = await youtube_service.analyze(url, include_raw=True)
151
- processing_time = round(time.monotonic() - start_time, 3)
152
-
153
- # If download succeeded, we have audio to analyze
154
- # For now, use fusion with external service results + preview features
155
- # TODO: When YouTube pipeline returns audio path, run full extraction
156
-
157
- # Use preview model for feature scores until
158
- # YouTube pipeline returns raw audio for local
159
- # extraction with full feature + vocal analysis
160
- preview = preview_model.analyze(yt_result.source.video_id)
161
- features_data = preview.get("features", {})
162
-
163
- decision = yt_result.summary.decision_source
164
- result = AnalysisResult(
165
- isAIGenerated=yt_result.summary.is_ai_generated,
166
- confidence=yt_result.summary.confidence,
167
- processingTime=processing_time,
168
- modelVersion=yt_result.summary.model_version,
169
- decisionSource=decision,
170
- analysisMode="preview" if decision == "preview" else "production",
171
- source={
172
- "kind": "youtube",
173
- "url": url,
174
- "normalizedUrl": yt_result.source.normalized_url,
175
- "videoId": yt_result.source.video_id,
176
- "startTimeSec": yt_result.source.start_time_sec
177
- },
178
- features=AnalysisFeatures(
179
- spectralRegularity=features_data.get(
180
- "spectral_regularity", 0.5,
181
- ),
182
- temporalPatterns=features_data.get(
183
- "temporal_patterns", 0.5,
184
- ),
185
- harmonicStructure=features_data.get(
186
- "harmonic_structure", 0.5,
187
- ),
188
- artificialIndicators=yt_result.summary.indicators
189
- ),
190
- audioInfo=AudioInfo(
191
- duration=yt_result.source.duration_sec or 0.0,
192
- sampleRate=44100,
193
- bitrate=128,
194
- format=yt_result.source.audio_format or "unknown"
195
- )
196
- )
197
-
198
- return AnalyzeResponse(
199
- result=result,
200
- warnings=yt_result.warnings,
201
- errors=yt_result.errors,
202
- )
203
 
204
- except ValueError as e:
205
- logger.warning(f"[{request_id}] Invalid URL: {e}")
206
- return AnalyzeResponse(errors=["invalid_youtube_url"])
207
- except Exception as e:
208
- logger.error(f"[{request_id}] YouTube failed: {e}")
209
- return AnalyzeResponse(errors=["youtube_analysis_failed"])
210
-
211
-
212
- async def _analyze_file(
213
  request_id: str,
214
- file: UploadFile,
 
 
 
 
215
  start_time: float,
 
 
 
216
  ) -> AnalyzeResponse:
217
- """
218
- Full audio analysis pipeline for uploaded files.
219
-
220
- Pipeline:
221
- 1. Validate file
222
- 2. Extract audio features (spectral, temporal, harmonic)
223
- 3. Analyze vocals (pitch, vibrato, formant, breath)
224
- 4. Fuse all scores into final result
225
- """
226
- content_type = file.content_type or ""
227
- if not content_type.startswith("audio/"):
228
- return AnalyzeResponse(errors=["invalid_file_type"])
229
-
230
- content = await file.read()
231
  file_size = len(content)
232
-
233
- if file_size > 30 * 1024 * 1024:
234
- return AnalyzeResponse(errors=["file_too_large"])
235
-
236
- if file_size < 1024:
237
- return AnalyzeResponse(errors=["file_too_small"])
238
-
239
- mb = file_size / 1024 / 1024
240
- logger.info(
241
- f"[{request_id}] File: {file.filename} "
242
- f"({mb:.2f}MB)"
243
- )
244
-
245
- warnings: List[str] = []
246
- loop = asyncio.get_event_loop()
247
-
248
- # ── Run feature extraction + vocal analysis + CLAP + wav2vec2 in parallel ──
249
- def _extract_features_sync():
250
- return extract_features(io.BytesIO(content))
251
-
252
- def _analyze_vocals_sync():
253
- return analyze_vocals(io.BytesIO(content))
254
-
255
- def _clap_predict_sync():
256
- return clap_service.predict(io.BytesIO(content))
257
-
258
- def _wav2vec2_predict_sync():
259
- return wav2vec2_service.predict(io.BytesIO(content))
260
-
261
- feat_future = loop.run_in_executor(_analysis_pool, _extract_features_sync)
262
- vocal_future = loop.run_in_executor(_analysis_pool, _analyze_vocals_sync)
263
- clap_future = loop.run_in_executor(_analysis_pool, _clap_predict_sync)
264
- wav2vec2_future = loop.run_in_executor(_analysis_pool, _wav2vec2_predict_sync)
265
-
266
- # Await feature extraction first (required for fusion)
267
  try:
268
  features: AudioFeatures = await feat_future
269
  logger.info(
@@ -273,17 +249,18 @@ async def _analyze_file(
273
  f"H={features.harmonic_structure:.3f}"
274
  )
275
  except ValueError as e:
276
- logger.warning(f"[{request_id}] Feature extraction failed: {e}")
277
- return _fallback_analysis(
278
- request_id, file, content, file_size, start_time, str(e),
279
- )
 
 
280
  except Exception as e:
281
  logger.error(f"[{request_id}] Feature extraction error: {e}", exc_info=True)
282
- return _fallback_analysis(
283
- request_id, file, content, file_size, start_time, str(e),
284
- )
285
 
286
- # Await vocal analysis (ran in parallel)
287
  vocals: Optional[VocalFeatures] = None
288
  try:
289
  vocals = await vocal_future
@@ -299,106 +276,106 @@ async def _analyze_file(
299
  logger.warning(f"[{request_id}] Vocal analysis failed: {e}")
300
  warnings.append("vocal_analysis_unavailable")
301
 
302
- # Await CLAP (ran in parallel)
303
  clap_result = None
304
  try:
305
  clap_result = await clap_future
306
  if clap_result.available:
307
  logger.info(
308
- f"[{request_id}] CLAP: "
309
- f"ai={clap_result.is_ai}, "
310
- f"conf={clap_result.confidence:.3f}"
311
  )
312
  else:
313
  logger.info(f"[{request_id}] CLAP unavailable: {clap_result.error}")
 
314
  except Exception as e:
315
  logger.warning(f"[{request_id}] CLAP failed: {e}")
316
  warnings.append("clap_analysis_unavailable")
317
 
318
- # Await wav2vec2 (ran in parallel)
319
  wav2vec2_result = None
320
  try:
321
  wav2vec2_result = await wav2vec2_future
322
  if wav2vec2_result.available:
323
- logger.info(
324
- f"[{request_id}] wav2vec2: "
325
- f"p_ai={wav2vec2_result.p_ai:.3f}"
326
- )
327
  else:
328
  logger.info(f"[{request_id}] wav2vec2 unavailable: {wav2vec2_result.error}")
 
329
  except Exception as e:
330
  logger.warning(f"[{request_id}] wav2vec2 failed: {e}")
331
  warnings.append("wav2vec2_unavailable")
332
 
333
- # FST detection (Layer 3) — async native
334
  fst_result = None
 
335
  try:
336
  fst_result = await fst_service.predict(io.BytesIO(content))
337
  if fst_result.available:
 
338
  logger.info(
339
- f"[{request_id}] FST: "
340
- f"ai={fst_result.is_ai}, "
341
- f"conf={fst_result.confidence:.3f}, "
342
- f"label={fst_result.label}"
343
  )
344
  else:
 
345
  logger.info(f"[{request_id}] FST unavailable: {fst_result.error}")
 
346
  except Exception as e:
 
347
  logger.warning(f"[{request_id}] FST failed: {e}")
348
  warnings.append("fst_analysis_unavailable")
349
 
350
- # ── Step 5: Score fusion ───────────────
351
  fusion: FusionResult = fuse_scores(
352
  features, vocals,
353
  clap_result=clap_result,
354
  fst_result=fst_result,
355
  wav2vec2_result=wav2vec2_result,
356
  )
357
-
358
- processing_time = round(time.monotonic() - start_time, 3)
359
  logger.info(
360
  f"[{request_id}] Fusion: ai={fusion.is_ai_generated}, "
361
  f"confidence={fusion.confidence}, source={fusion.decision_source}"
362
  )
363
 
364
- # ── Step 5b: XAI inference (explainable) ────
365
  xai_payload: Optional[XAIExplanation] = None
 
366
  try:
367
- if xai_service.available:
368
- xai_result = xai_service.predict(features, vocals)
369
- if xai_result is not None:
370
- xai_dict = xai_service.to_dict(xai_result)
371
- # Convert dicts to pydantic models
372
- xai_payload = XAIExplanation(
373
- probability=xai_dict["probability"],
374
- threshold=xai_dict["threshold"],
375
- baseProbability=xai_dict["baseProbability"],
376
- confidenceBand=ConfidenceBandModel(**xai_dict["confidenceBand"]),
377
- modelVotes=[ModelVoteModel(**v) for v in xai_dict["modelVotes"]],
378
- bestModel=xai_dict["bestModel"],
379
- topContributions=[
380
- FeatureContributionModel(**c)
381
- for c in xai_dict["topContributions"]
382
- ],
383
- allFeatures={
384
- k: FeatureContributionModel(**v)
385
- for k, v in xai_dict["allFeatures"].items()
386
- },
387
- featureCount=xai_dict["featureCount"],
388
- )
389
- logger.info(
390
- f"[{request_id}] XAI: p={xai_result.probability:.3f}, "
391
- f"band={xai_result.confidence_band.tier}, "
392
- f"top={xai_result.top_contributions[0].name if xai_result.top_contributions else 'n/a'}"
393
- )
 
 
394
  except Exception as e:
 
395
  logger.warning(f"[{request_id}] XAI failed: {e}")
396
  warnings.append("xai_unavailable")
397
 
398
- # ── Step 5c: Meta-classifier (trained stacking ensemble) ────
399
- # A second, independent opinion shown for transparency — never
400
- # overrides the primary XAI/fusion verdict above.
401
  meta_payload: Optional[MetaClassifierExplanation] = None
 
402
  try:
403
  meta_result = meta_classifier_service.predict(
404
  features, vocals=vocals,
@@ -415,15 +392,20 @@ async def _analyze_file(
415
  MetaFeatureImportance(**f) for f in meta_result.top_features
416
  ],
417
  )
 
418
  logger.info(
419
  f"[{request_id}] Meta-classifier: ai={meta_result.is_ai_generated}, "
420
  f"confidence={meta_result.confidence}"
421
  )
 
 
 
422
  except Exception as e:
 
423
  logger.warning(f"[{request_id}] Meta-classifier failed: {e}")
424
  warnings.append("meta_classifier_unavailable")
425
 
426
- # Build tower scores dict for UI (multi-signal breakdown)
427
  tower_scores: Dict[str, float] = {
428
  "local_features": round(
429
  (features.spectral_regularity + features.temporal_patterns
@@ -441,7 +423,29 @@ async def _analyze_file(
441
  if xai_payload is not None:
442
  tower_scores["xai_ensemble"] = round(xai_payload.probability, 3)
443
 
444
- # ── Step 4: Build response ─────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  vocal_response = None
446
  if vocals and vocals.has_vocals:
447
  vocal_response = VocalAnalysis(
@@ -460,17 +464,14 @@ async def _analyze_file(
460
  indicators=vocals.indicators,
461
  )
462
 
463
- # Estimate bitrate from file size + duration (kbps)
464
- estimated_bitrate = 128
465
- if features.duration_sec > 0:
466
- estimated_bitrate = max(
467
- 32, min(
468
- int((file_size * 8) / (features.duration_sec * 1000)),
469
- 2048,
470
- )
471
- )
472
 
473
- # Prefer XAI verdict if available — it's the calibrated classifier
474
  final_is_ai = fusion.is_ai_generated
475
  final_confidence = fusion.confidence
476
  final_decision = fusion.decision_source
@@ -482,16 +483,11 @@ async def _analyze_file(
482
  result = AnalysisResult(
483
  isAIGenerated=final_is_ai,
484
  confidence=final_confidence,
485
- processingTime=processing_time,
486
  modelVersion=fusion.model_version,
487
  decisionSource=final_decision,
488
  analysisMode=fusion.analysis_mode,
489
- source={
490
- "kind": "file",
491
- "fileName": file.filename or "unknown",
492
- "fileSizeBytes": file_size,
493
- "mimeType": content_type,
494
- },
495
  features=AnalysisFeatures(
496
  spectralRegularity=fusion.spectral_regularity,
497
  temporalPatterns=fusion.temporal_patterns,
@@ -499,76 +495,212 @@ async def _analyze_file(
499
  artificialIndicators=fusion.indicators,
500
  ),
501
  audioInfo=AudioInfo(
502
- duration=round(features.duration_sec, 2),
503
- sampleRate=features.sample_rate,
504
- bitrate=estimated_bitrate,
505
- format=content_type.replace("audio/", ""),
506
- channels=1,
 
507
  ),
508
  vocalAnalysis=vocal_response,
509
  xai=xai_payload,
510
  towerScores=tower_scores,
511
  metaClassifier=meta_payload,
 
512
  )
513
 
514
- return AnalyzeResponse(result=result, warnings=warnings, errors=[])
515
 
516
 
517
- def _fallback_analysis(
518
- request_id: str,
519
- file: UploadFile,
520
- content: bytes,
521
- file_size: int,
522
- start_time: float,
523
- reason: str,
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  ) -> AnalyzeResponse:
525
- """Fallback to preview model when real analysis fails."""
526
- logger.warning(f"[{request_id}] Falling back to preview model: {reason}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
 
528
- fingerprint = f"file_{file.filename}_{file_size}"
529
- preview = preview_model.analyze(fingerprint)
530
- features_data = preview.get("features", {})
531
- processing_time = round(time.monotonic() - start_time, 3)
532
 
533
- result = AnalysisResult(
534
- isAIGenerated=preview["is_ai_generated"],
535
- confidence=preview["confidence"],
536
- processingTime=processing_time,
537
- modelVersion=preview["model_version"],
538
- decisionSource="preview",
539
- analysisMode="preview",
540
- source={
541
- "kind": "file",
542
- "fileName": file.filename or "unknown",
543
- "fileSizeBytes": file_size,
544
- "mimeType": file.content_type or "audio/unknown",
545
- },
546
- features=AnalysisFeatures(
547
- spectralRegularity=features_data.get(
548
- "spectral_regularity", 0.5,
549
- ),
550
- temporalPatterns=features_data.get(
551
- "temporal_patterns", 0.5,
552
- ),
553
- harmonicStructure=features_data.get(
554
- "harmonic_structure", 0.5,
555
- ),
556
- artificialIndicators=preview.get(
557
- "indicators", [],
558
- ),
559
- ),
560
- audioInfo=AudioInfo(
561
- duration=0.0,
562
- sampleRate=44100,
563
- bitrate=128,
564
- format=(
565
- file.content_type or "audio/unknown"
566
- ).replace("audio/", ""),
567
- ),
568
  )
569
 
570
- return AnalyzeResponse(
571
- result=result,
572
- warnings=["analysis_fallback_preview", f"reason: {reason}"],
573
- errors=[],
574
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """General analysis route for CrownCode.
2
 
3
+ Handles YouTube URLs and file uploads through a unified pipeline.
4
  Response format matches platform/hooks/analysisTypes.ts
5
 
6
  Pipeline:
7
  1. Source acquisition (YouTube download or file upload)
8
+ 2. Feature extraction, vocal analysis, CLAP and wav2vec2 in parallel
9
+ 3. FST comparison model (external Space)
10
+ 4. Score fusion, then the calibrated XAI verdict (LightGBM + votes + SHAP)
11
+ 5. Meta-classifier second opinion, response formatting
12
+
13
+ Endpoints:
14
+ POST /api/analyze one request, waits for the result
15
+ POST /api/analyze/jobs starts a background job, returns its id
16
+ GET /api/analyze/jobs/{job_id} per-step progress, then the result
17
+
18
+ Nothing here invents a result: when the audio can't be decoded or a
19
+ link can't be downloaded, the response carries an error, not a guess.
20
  """
21
 
22
  from __future__ import annotations
23
 
24
  import asyncio
25
  import io
26
+ import tempfile
27
  import time
28
  import uuid
29
  from collections import defaultdict
30
  from concurrent.futures import ThreadPoolExecutor
31
+ from pathlib import Path
32
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
33
+
34
  from fastapi import (
35
  APIRouter, File, UploadFile, Form,
36
  Request, Depends, HTTPException, status,
37
  )
38
+ from fastapi.responses import JSONResponse
39
 
40
  from .analyze_schemas import (
41
  AnalysisFeatures,
 
50
  VocalAnalysis,
51
  XAIExplanation,
52
  )
53
+ from ..services.analysis_jobs import (
54
+ FILE_STEPS,
55
+ URL_STEPS,
56
+ STEP_DONE,
57
+ STEP_FAILED,
58
+ STEP_RUNNING,
59
+ STEP_SKIPPED,
60
+ Progress,
61
+ job_store,
62
+ )
63
  from ..services.feature_extractor import (
64
  extract_features,
65
  AudioFeatures,
 
74
  from ..services.wav2vec2_detector import Wav2Vec2DetectorService
75
  from ..services.inference_xai import get_xai_service
76
  from ..services.meta_classifier import MetaClassifierService, MODEL_VERSION_TRAINED
77
+ from ..services.url_parser import parse_youtube_url
78
+ from ..services.youtube_downloader import YouTubeDownloadError, YouTubeDownloader
79
  from ..services.logging_config import get_logger
80
 
81
  router = APIRouter()
82
  logger = get_logger(__name__)
83
 
84
+ MAX_UPLOAD_BYTES = 30 * 1024 * 1024
85
+ MIN_UPLOAD_BYTES = 1024
86
+ AUDIO_EXTENSIONS = {".mp3", ".wav", ".flac", ".m4a", ".mp4", ".aac", ".ogg", ".opus", ".webm"}
87
+ _EXT_MIME = {
88
+ ".mp3": "audio/mpeg", ".wav": "audio/wav", ".flac": "audio/flac",
89
+ ".m4a": "audio/mp4", ".mp4": "audio/mp4", ".aac": "audio/aac",
90
+ ".ogg": "audio/ogg", ".opus": "audio/ogg", ".webm": "audio/webm",
91
+ }
92
+ URL_SOURCES = ("youtube", "tiktok", "instagram", "soundcloud", "twitter")
93
+
94
+ # A link only needs its opening: features read 60 s, wav2vec2 30 s.
95
+ YOUTUBE_CLIP_SEC = 120.0
96
+ YOUTUBE_MAX_BYTES = 100 * 1024 * 1024
97
+
98
+ # cpu-basic has 2 vCPUs; more concurrent jobs only queue in the pool.
99
+ MAX_RUNNING_JOBS = 4
100
+
101
  # Rate limiter for public heavy endpoints
102
  _analyze_rate_store: dict[str, list[float]] = defaultdict(list)
103
  _ANALYZE_RATE_WINDOW = 60 # seconds
 
105
  _ANALYZE_MAX_IPS = 10_000
106
 
107
 
108
+ def _client_ip(request: Request) -> str:
109
+ """The visitor's address behind the Hugging Face proxy.
110
+
111
+ uvicorn sees the proxy, so request.client alone would put every
112
+ visitor in one bucket; the proxy lists the original client first
113
+ in X-Forwarded-For.
114
+ """
115
+ forwarded = request.headers.get("x-forwarded-for", "")
116
+ first = forwarded.split(",")[0].strip()
117
+ if first:
118
+ return first
119
+ return request.client.host if request.client else "unknown"
120
+
121
+
122
  async def _analyze_rate_limit(request: Request) -> None:
123
  """Rate limit for analysis endpoints."""
124
+ client_ip = _client_ip(request)
125
  now = time.time()
126
  cutoff = now - _ANALYZE_RATE_WINDOW
127
 
 
146
  _analyze_rate_store[client_ip] = hits
147
 
148
 
 
 
149
  clap_service = CLAPDetectorService()
150
  fst_service = FSTClientService()
151
  wav2vec2_service = Wav2Vec2DetectorService()
 
154
 
155
  # Thread pool for CPU-bound audio analysis (feature extraction + vocal)
156
  _analysis_pool = ThreadPoolExecutor(max_workers=3)
157
+ # Downloads wait on the network; keep them off the analysis workers.
158
+ _download_pool = ThreadPoolExecutor(max_workers=2)
159
 
160
 
161
+ # ── helpers ───────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
+ def _unique(values: List[str]) -> List[str]:
164
+ return list(dict.fromkeys(values))
 
 
165
 
 
 
166
 
167
+ def _probe_audio(content: bytes) -> Tuple[Optional[int], Optional[int], Optional[float]]:
168
+ """Original sample rate, channels and full duration, when soundfile can
169
+ read the container (WAV/FLAC/OGG/MP3). The analysis itself loads mono
170
+ at 22.05 kHz and only the first 60 s, so it can't tell these."""
171
+ try:
172
+ import soundfile as sf
173
+ info = sf.info(io.BytesIO(content))
174
+ return int(info.samplerate), int(info.channels), float(info.duration)
175
+ except Exception:
176
+ return None, None, None
177
+
178
+
179
+ def _track(future: "asyncio.Future[Any]", progress: Progress, step: str,
180
+ ok: Optional[Callable[[Any], bool]] = None) -> None:
181
+ """Report a parallel step as done/skipped/failed the moment it settles."""
182
+ def _settled(f: "asyncio.Future[Any]") -> None:
183
+ if f.cancelled() or f.exception() is not None:
184
+ progress.set(step, STEP_FAILED)
185
+ elif ok is not None and not ok(f.result()):
186
+ progress.set(step, STEP_SKIPPED)
187
  else:
188
+ progress.set(step, STEP_DONE)
189
+ future.add_done_callback(_settled)
190
+
191
+
192
+ async def _read_upload(file: UploadFile) -> Tuple[Optional[bytes], Optional[str], str]:
193
+ """Validate an upload. Returns (content, error_code, content_type)."""
194
+ filename = file.filename or "audio"
195
+ ext = Path(filename).suffix.lower()
196
+ content_type = (file.content_type or "").lower()
197
+ # Browsers label some audio containers as video (webm, mp4); trust the extension then.
198
+ if not (content_type.startswith("audio/") or ext in AUDIO_EXTENSIONS):
199
+ return None, "invalid_file_type", content_type
200
+ if not content_type.startswith("audio/"):
201
+ content_type = _EXT_MIME.get(ext, "audio/unknown")
202
 
203
+ content = await file.read(MAX_UPLOAD_BYTES + 1)
204
+ if len(content) > MAX_UPLOAD_BYTES:
205
+ return None, "file_too_large", content_type
206
+ if len(content) < MIN_UPLOAD_BYTES:
207
+ return None, "file_too_small", content_type
208
+ return content, None, content_type
209
 
210
 
211
+ # ── the pipeline ──────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
 
213
+ async def _run_pipeline(
 
 
 
 
 
 
 
 
214
  request_id: str,
215
+ content: bytes,
216
+ *,
217
+ filename: str,
218
+ content_type: str,
219
+ source: Dict[str, Any],
220
  start_time: float,
221
+ progress: Progress,
222
+ warnings: Optional[List[str]] = None,
223
+ total_duration: Optional[float] = None,
224
  ) -> AnalyzeResponse:
225
+ """Run every layer on one piece of audio and build the response."""
226
+ warnings = list(warnings or [])
 
 
 
 
 
 
 
 
 
 
 
 
227
  file_size = len(content)
228
+ logger.info(f"[{request_id}] Audio: {filename} ({file_size / 1024 / 1024:.2f}MB)")
229
+ loop = asyncio.get_running_loop()
230
+
231
+ # ── Feature extraction + vocal analysis + CLAP + wav2vec2 in parallel ──
232
+ progress.running("features", "vocals", "clap", "wav2vec2")
233
+ feat_future = loop.run_in_executor(_analysis_pool, lambda: extract_features(io.BytesIO(content)))
234
+ vocal_future = loop.run_in_executor(_analysis_pool, lambda: analyze_vocals(io.BytesIO(content)))
235
+ clap_future = loop.run_in_executor(_analysis_pool, lambda: clap_service.predict(io.BytesIO(content)))
236
+ wav2vec2_future = loop.run_in_executor(_analysis_pool, lambda: wav2vec2_service.predict(io.BytesIO(content)))
237
+ _track(feat_future, progress, "features")
238
+ _track(vocal_future, progress, "vocals")
239
+ _track(clap_future, progress, "clap", ok=lambda r: bool(r.available))
240
+ _track(wav2vec2_future, progress, "wav2vec2", ok=lambda r: bool(r.available))
241
+
242
+ # Feature extraction is required for everything below.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  try:
244
  features: AudioFeatures = await feat_future
245
  logger.info(
 
249
  f"H={features.harmonic_structure:.3f}"
250
  )
251
  except ValueError as e:
252
+ logger.warning(f"[{request_id}] Feature extraction rejected the audio: {e}")
253
+ for step in ("fst", "xai", "meta"):
254
+ progress.set(step, STEP_SKIPPED)
255
+ message = str(e).lower()
256
+ code = "audio_too_short" if "short" in message else "audio_silent" if "silent" in message else "audio_decode_failed"
257
+ return AnalyzeResponse(errors=[code], warnings=_unique(warnings))
258
  except Exception as e:
259
  logger.error(f"[{request_id}] Feature extraction error: {e}", exc_info=True)
260
+ for step in ("fst", "xai", "meta"):
261
+ progress.set(step, STEP_SKIPPED)
262
+ return AnalyzeResponse(errors=["audio_decode_failed"], warnings=_unique(warnings))
263
 
 
264
  vocals: Optional[VocalFeatures] = None
265
  try:
266
  vocals = await vocal_future
 
276
  logger.warning(f"[{request_id}] Vocal analysis failed: {e}")
277
  warnings.append("vocal_analysis_unavailable")
278
 
 
279
  clap_result = None
280
  try:
281
  clap_result = await clap_future
282
  if clap_result.available:
283
  logger.info(
284
+ f"[{request_id}] CLAP ({clap_result.classifier_used}): "
285
+ f"ai={clap_result.is_ai}, conf={clap_result.confidence:.3f}"
 
286
  )
287
  else:
288
  logger.info(f"[{request_id}] CLAP unavailable: {clap_result.error}")
289
+ warnings.append("clap_analysis_unavailable")
290
  except Exception as e:
291
  logger.warning(f"[{request_id}] CLAP failed: {e}")
292
  warnings.append("clap_analysis_unavailable")
293
 
 
294
  wav2vec2_result = None
295
  try:
296
  wav2vec2_result = await wav2vec2_future
297
  if wav2vec2_result.available:
298
+ logger.info(f"[{request_id}] wav2vec2: p_ai={wav2vec2_result.p_ai:.3f}")
 
 
 
299
  else:
300
  logger.info(f"[{request_id}] wav2vec2 unavailable: {wav2vec2_result.error}")
301
+ warnings.append("wav2vec2_unavailable")
302
  except Exception as e:
303
  logger.warning(f"[{request_id}] wav2vec2 failed: {e}")
304
  warnings.append("wav2vec2_unavailable")
305
 
306
+ # ── FST detection (external Space) ──
307
  fst_result = None
308
+ progress.set("fst", STEP_RUNNING)
309
  try:
310
  fst_result = await fst_service.predict(io.BytesIO(content))
311
  if fst_result.available:
312
+ progress.set("fst", STEP_DONE)
313
  logger.info(
314
+ f"[{request_id}] FST: ai={fst_result.is_ai}, "
315
+ f"conf={fst_result.confidence:.3f}, label={fst_result.label}"
 
 
316
  )
317
  else:
318
+ progress.set("fst", STEP_SKIPPED)
319
  logger.info(f"[{request_id}] FST unavailable: {fst_result.error}")
320
+ warnings.append("fst_analysis_unavailable")
321
  except Exception as e:
322
+ progress.set("fst", STEP_FAILED)
323
  logger.warning(f"[{request_id}] FST failed: {e}")
324
  warnings.append("fst_analysis_unavailable")
325
 
326
+ # ── Score fusion ──
327
  fusion: FusionResult = fuse_scores(
328
  features, vocals,
329
  clap_result=clap_result,
330
  fst_result=fst_result,
331
  wav2vec2_result=wav2vec2_result,
332
  )
 
 
333
  logger.info(
334
  f"[{request_id}] Fusion: ai={fusion.is_ai_generated}, "
335
  f"confidence={fusion.confidence}, source={fusion.decision_source}"
336
  )
337
 
338
+ # ── XAI inference (calibrated verdict + votes + SHAP) ──
339
  xai_payload: Optional[XAIExplanation] = None
340
+ progress.set("xai", STEP_RUNNING)
341
  try:
342
+ xai_result = xai_service.predict(features, vocals) if xai_service.available else None
343
+ if xai_result is not None:
344
+ xai_dict = xai_service.to_dict(xai_result)
345
+ xai_payload = XAIExplanation(
346
+ probability=xai_dict["probability"],
347
+ threshold=xai_dict["threshold"],
348
+ baseProbability=xai_dict["baseProbability"],
349
+ confidenceBand=ConfidenceBandModel(**xai_dict["confidenceBand"]),
350
+ modelVotes=[ModelVoteModel(**v) for v in xai_dict["modelVotes"]],
351
+ bestModel=xai_dict["bestModel"],
352
+ topContributions=[
353
+ FeatureContributionModel(**c)
354
+ for c in xai_dict["topContributions"]
355
+ ],
356
+ allFeatures={
357
+ k: FeatureContributionModel(**v)
358
+ for k, v in xai_dict["allFeatures"].items()
359
+ },
360
+ featureCount=xai_dict["featureCount"],
361
+ )
362
+ progress.set("xai", STEP_DONE)
363
+ logger.info(
364
+ f"[{request_id}] XAI: p={xai_result.probability:.3f}, "
365
+ f"band={xai_result.confidence_band.tier}, "
366
+ f"top={xai_result.top_contributions[0].name if xai_result.top_contributions else 'n/a'}"
367
+ )
368
+ else:
369
+ progress.set("xai", STEP_SKIPPED)
370
+ warnings.append("xai_unavailable")
371
  except Exception as e:
372
+ progress.set("xai", STEP_FAILED)
373
  logger.warning(f"[{request_id}] XAI failed: {e}")
374
  warnings.append("xai_unavailable")
375
 
376
+ # ── Meta-classifier: a second opinion, never overrides the verdict ──
 
 
377
  meta_payload: Optional[MetaClassifierExplanation] = None
378
+ progress.set("meta", STEP_RUNNING)
379
  try:
380
  meta_result = meta_classifier_service.predict(
381
  features, vocals=vocals,
 
392
  MetaFeatureImportance(**f) for f in meta_result.top_features
393
  ],
394
  )
395
+ progress.set("meta", STEP_DONE)
396
  logger.info(
397
  f"[{request_id}] Meta-classifier: ai={meta_result.is_ai_generated}, "
398
  f"confidence={meta_result.confidence}"
399
  )
400
+ else:
401
+ progress.set("meta", STEP_SKIPPED)
402
+ warnings.append("meta_classifier_unavailable")
403
  except Exception as e:
404
+ progress.set("meta", STEP_FAILED)
405
  logger.warning(f"[{request_id}] Meta-classifier failed: {e}")
406
  warnings.append("meta_classifier_unavailable")
407
 
408
+ # ── Tower scores (P(AI) per independent layer) ──
409
  tower_scores: Dict[str, float] = {
410
  "local_features": round(
411
  (features.spectral_regularity + features.temporal_patterns
 
423
  if xai_payload is not None:
424
  tower_scores["xai_ensemble"] = round(xai_payload.probability, 3)
425
 
426
+ # Which implementation each layer actually used this time.
427
+ layers: Dict[str, Dict[str, Any]] = {
428
+ "wav2vec2": {
429
+ "available": bool(wav2vec2_result and wav2vec2_result.available),
430
+ "error": getattr(wav2vec2_result, "error", None),
431
+ },
432
+ "clap": {
433
+ "available": bool(clap_result and clap_result.available),
434
+ "mode": getattr(clap_result, "classifier_used", None),
435
+ "error": getattr(clap_result, "error", None),
436
+ },
437
+ "fst": {
438
+ "available": bool(fst_result and fst_result.available),
439
+ "label": getattr(fst_result, "label", None),
440
+ "error": getattr(fst_result, "error", None),
441
+ },
442
+ "xai": {
443
+ "available": xai_payload is not None,
444
+ "ensembleLoaded": len(xai_service.ensemble_models),
445
+ },
446
+ "meta": {"available": meta_payload is not None},
447
+ }
448
+
449
  vocal_response = None
450
  if vocals and vocals.has_vocals:
451
  vocal_response = VocalAnalysis(
 
464
  indicators=vocals.indicators,
465
  )
466
 
467
+ # ── Audio facts: from the container when readable, else what was analysed ──
468
+ orig_sr, orig_channels, probed_duration = _probe_audio(content)
469
+ duration = total_duration or probed_duration or features.duration_sec
470
+ bitrate = 0
471
+ if probed_duration and probed_duration > 0:
472
+ bitrate = max(1, int((file_size * 8) / (probed_duration * 1000)))
 
 
 
473
 
474
+ # Prefer the XAI verdict — it's the calibrated classifier.
475
  final_is_ai = fusion.is_ai_generated
476
  final_confidence = fusion.confidence
477
  final_decision = fusion.decision_source
 
483
  result = AnalysisResult(
484
  isAIGenerated=final_is_ai,
485
  confidence=final_confidence,
486
+ processingTime=round(time.monotonic() - start_time, 3),
487
  modelVersion=fusion.model_version,
488
  decisionSource=final_decision,
489
  analysisMode=fusion.analysis_mode,
490
+ source=source,
 
 
 
 
 
491
  features=AnalysisFeatures(
492
  spectralRegularity=fusion.spectral_regularity,
493
  temporalPatterns=fusion.temporal_patterns,
 
495
  artificialIndicators=fusion.indicators,
496
  ),
497
  audioInfo=AudioInfo(
498
+ duration=round(duration, 2),
499
+ analysedSec=round(features.duration_sec, 2),
500
+ sampleRate=orig_sr or features.sample_rate,
501
+ bitrate=bitrate,
502
+ format=Path(filename).suffix.lstrip(".").lower() or content_type.replace("audio/", ""),
503
+ channels=orig_channels or 0,
504
  ),
505
  vocalAnalysis=vocal_response,
506
  xai=xai_payload,
507
  towerScores=tower_scores,
508
  metaClassifier=meta_payload,
509
+ layers=layers,
510
  )
511
 
512
+ return AnalyzeResponse(result=result, warnings=_unique(warnings), errors=[])
513
 
514
 
515
+ # ── sources ───────────────────────────────────────────────────────────
516
+
517
+ def _download_youtube_sync(
518
+ normalized_url: str, video_id: str, start_sec: Optional[int],
519
+ ) -> Tuple[bytes, str, Optional[str], Optional[float], List[str]]:
520
+ """Download the opening of a video (from ?t= if given) to memory."""
521
+ start = float(start_sec or 0)
522
+ with tempfile.TemporaryDirectory() as tmp:
523
+ downloader = YouTubeDownloader(
524
+ Path(tmp),
525
+ clip=(start, start + YOUTUBE_CLIP_SEC),
526
+ max_filesize=YOUTUBE_MAX_BYTES,
527
+ )
528
+ result = downloader.download(normalized_url, video_id)
529
+ content = result.file_path.read_bytes()
530
+ return content, result.file_path.suffix.lower(), result.title, result.duration_sec, result.warnings
531
+
532
+
533
+ async def _analyze_url(
534
+ request_id: str, url: str, start_time: float, progress: Progress,
535
  ) -> AnalyzeResponse:
536
+ """Download a YouTube link's audio and run the same pipeline as a file."""
537
+ try:
538
+ parsed = parse_youtube_url(url)
539
+ except ValueError as e:
540
+ logger.warning(f"[{request_id}] Invalid URL: {e}")
541
+ progress.set("download", STEP_FAILED)
542
+ return AnalyzeResponse(errors=["invalid_youtube_url"])
543
+
544
+ progress.set("download", STEP_RUNNING)
545
+ loop = asyncio.get_running_loop()
546
+ try:
547
+ content, ext, title, duration, dl_warnings = await loop.run_in_executor(
548
+ _download_pool, _download_youtube_sync,
549
+ parsed.normalized_url, parsed.video_id, parsed.start_time_sec,
550
+ )
551
+ except YouTubeDownloadError as exc:
552
+ progress.set("download", STEP_FAILED)
553
+ logger.warning(f"[{request_id}] YouTube download failed ({exc.error_code}): {exc}")
554
+ errors = ["youtube_analysis_failed"]
555
+ if exc.error_code == "youtube_authentication_required":
556
+ errors.insert(0, "youtube_authentication_required")
557
+ return AnalyzeResponse(errors=errors, warnings=_unique(exc.warnings))
558
+ except Exception as exc:
559
+ progress.set("download", STEP_FAILED)
560
+ logger.error(f"[{request_id}] YouTube download error: {exc}", exc_info=True)
561
+ return AnalyzeResponse(errors=["youtube_analysis_failed"])
562
+ progress.set("download", STEP_DONE)
563
 
564
+ if len(content) < MIN_UPLOAD_BYTES:
565
+ return AnalyzeResponse(errors=["youtube_analysis_failed"])
 
 
566
 
567
+ source: Dict[str, Any] = {
568
+ "kind": "youtube",
569
+ "url": url,
570
+ "normalizedUrl": parsed.normalized_url,
571
+ "videoId": parsed.video_id,
572
+ "startTimeSec": parsed.start_time_sec,
573
+ }
574
+ if title:
575
+ source["title"] = title
576
+
577
+ return await _run_pipeline(
578
+ request_id, content,
579
+ filename=f"{parsed.video_id}{ext}",
580
+ content_type=_EXT_MIME.get(ext, "audio/wav"),
581
+ source=source,
582
+ start_time=start_time,
583
+ progress=progress,
584
+ warnings=dl_warnings,
585
+ total_duration=duration,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  )
587
 
588
+
589
+ Work = Callable[[Progress], Awaitable[AnalyzeResponse]]
590
+
591
+
592
+ async def _plan(
593
+ request_id: str,
594
+ source_type: str,
595
+ url: Optional[str],
596
+ file: Optional[UploadFile],
597
+ ) -> Tuple[Optional[AnalyzeResponse], Optional[Tuple[List[str], Work]]]:
598
+ """Validate a request. Returns an immediate error response, or the
599
+ steps plus the work to run (uploads are read here, while the request
600
+ is still open)."""
601
+ start_time = time.monotonic()
602
+ logger.info(f"[{request_id}] Analysis: sourceType={source_type}")
603
+
604
+ if source_type in URL_SOURCES:
605
+ if not url:
606
+ return AnalyzeResponse(errors=["missing_url"]), None
607
+ return None, (URL_STEPS, lambda p: _analyze_url(request_id, url, start_time, p))
608
+
609
+ if source_type == "file":
610
+ if not file:
611
+ return AnalyzeResponse(errors=["missing_file"]), None
612
+ content, error, content_type = await _read_upload(file)
613
+ if error or content is None:
614
+ return AnalyzeResponse(errors=[error or "invalid_file_type"]), None
615
+ filename = file.filename or "audio"
616
+ source = {
617
+ "kind": "file",
618
+ "fileName": filename,
619
+ "fileSizeBytes": len(content),
620
+ "mimeType": content_type,
621
+ }
622
+ return None, (FILE_STEPS, lambda p: _run_pipeline(
623
+ request_id, content,
624
+ filename=filename, content_type=content_type,
625
+ source=source, start_time=start_time, progress=p,
626
+ ))
627
+
628
+ if source_type in ("spotify", "apple"):
629
+ return AnalyzeResponse(errors=["unsupported_source"]), None
630
+ return AnalyzeResponse(errors=["invalid_source_type"]), None
631
+
632
+
633
+ # ── endpoints ─────────────────────────────────────────────────────────
634
+
635
+ @router.post(
636
+ "/api/analyze",
637
+ response_model=AnalyzeResponse,
638
+ dependencies=[Depends(_analyze_rate_limit)],
639
+ )
640
+ async def analyze(
641
+ sourceType: str = Form(...),
642
+ url: Optional[str] = Form(None),
643
+ file: Optional[UploadFile] = File(None)
644
+ ) -> AnalyzeResponse:
645
+ """Unified analysis endpoint (waits for the result)."""
646
+ request_id = str(uuid.uuid4())[:8]
647
+ try:
648
+ immediate, plan = await _plan(request_id, sourceType, url, file)
649
+ if immediate is not None or plan is None:
650
+ return immediate or AnalyzeResponse(errors=["internal_error"])
651
+ steps, work = plan
652
+ return await work(Progress(steps))
653
+ except Exception as e:
654
+ logger.error(f"[{request_id}] Error: {e}", exc_info=True)
655
+ return AnalyzeResponse(errors=["internal_error"])
656
+
657
+
658
+ @router.post(
659
+ "/api/analyze/jobs",
660
+ dependencies=[Depends(_analyze_rate_limit)],
661
+ )
662
+ async def start_analysis_job(
663
+ sourceType: str = Form(...),
664
+ url: Optional[str] = Form(None),
665
+ file: Optional[UploadFile] = File(None)
666
+ ) -> JSONResponse:
667
+ """Start an analysis in the background; poll GET /api/analyze/jobs/{jobId}."""
668
+ request_id = str(uuid.uuid4())[:8]
669
+ immediate, plan = await _plan(request_id, sourceType, url, file)
670
+ if immediate is not None or plan is None:
671
+ response = immediate or AnalyzeResponse(errors=["internal_error"])
672
+ return JSONResponse(status_code=200, content={
673
+ "jobId": None, "sourceType": sourceType, "status": "error",
674
+ "steps": [], "elapsedSec": 0, "response": response.model_dump(),
675
+ })
676
+
677
+ if job_store.running_count() >= MAX_RUNNING_JOBS:
678
+ raise HTTPException(
679
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
680
+ detail={"code": "server_busy", "message": "Too many analyses running. Try again shortly."},
681
+ )
682
+
683
+ steps, work = plan
684
+
685
+ async def run(progress: Progress) -> Dict[str, Any]:
686
+ try:
687
+ return (await work(progress)).model_dump()
688
+ except Exception as e:
689
+ logger.error(f"[{request_id}] Job error: {e}", exc_info=True)
690
+ return AnalyzeResponse(errors=["internal_error"]).model_dump()
691
+
692
+ job = job_store.start(sourceType, steps, run)
693
+ logger.info(f"[{request_id}] Job {job.id} started")
694
+ return JSONResponse(status_code=202, content=job.to_dict())
695
+
696
+
697
+ @router.get("/api/analyze/jobs/{job_id}")
698
+ async def get_analysis_job(job_id: str) -> Dict[str, Any]:
699
+ """Per-step progress of a job; `response` is filled once it settles."""
700
+ job = job_store.get(job_id)
701
+ if job is None:
702
+ raise HTTPException(
703
+ status_code=status.HTTP_404_NOT_FOUND,
704
+ detail={"code": "job_not_found", "message": "Unknown or expired job."},
705
+ )
706
+ return job.to_dict()
app/routes/analyze_schemas.py CHANGED
@@ -45,10 +45,14 @@ class AudioInfo(BaseModel):
45
  """Audio file information."""
46
 
47
  duration: float = 0.0
 
 
48
  sampleRate: int = 44100
49
- bitrate: int = 128
 
50
  format: str = "unknown"
51
- channels: int = 2
 
52
 
53
 
54
  class ConfidenceBandModel(BaseModel):
 
45
  """Audio file information."""
46
 
47
  duration: float = 0.0
48
+ # Seconds the models actually listened to (feature extraction reads the opening).
49
+ analysedSec: Optional[float] = None
50
  sampleRate: int = 44100
51
+ # 0 when unknown (container soundfile can't read).
52
+ bitrate: int = 0
53
  format: str = "unknown"
54
+ # 0 when unknown.
55
+ channels: int = 0
56
 
57
 
58
  class ConfidenceBandModel(BaseModel):
app/routes/health.py CHANGED
@@ -4,12 +4,43 @@ Health check route for the backend service.
4
 
5
  from __future__ import annotations
6
 
 
 
7
  from fastapi import APIRouter
8
 
9
  from ..services.external_clients import service_status
 
10
 
11
 
12
  router = APIRouter()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
 
15
  @router.get("/api/health")
@@ -17,4 +48,5 @@ async def health() -> dict:
17
  return {
18
  "status": "ok",
19
  "services": service_status(),
 
20
  }
 
4
 
5
  from __future__ import annotations
6
 
7
+ from typing import Any, Dict
8
+
9
  from fastapi import APIRouter
10
 
11
  from ..services.external_clients import service_status
12
+ from ..services.logging_config import get_logger
13
 
14
 
15
  router = APIRouter()
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ def _model_status() -> Dict[str, Any]:
20
+ """What the AURIS analysis actually has loaded right now.
21
+
22
+ Imported lazily: the analyze module builds the services at import
23
+ time, and health must answer even if that failed.
24
+ """
25
+ try:
26
+ from .analyze import (
27
+ clap_service,
28
+ meta_classifier_service,
29
+ wav2vec2_service,
30
+ xai_service,
31
+ )
32
+ from ..services.analysis_jobs import job_store
33
+
34
+ return {
35
+ "xai": xai_service.status(),
36
+ "wav2vec2": wav2vec2_service.status(),
37
+ "clap": clap_service.status(),
38
+ "meta": meta_classifier_service.status(),
39
+ "jobs": {"running": job_store.running_count()},
40
+ }
41
+ except Exception as e: # noqa: BLE001
42
+ logger.warning(f"Model status unavailable: {e}")
43
+ return {"error": "model_status_unavailable"}
44
 
45
 
46
  @router.get("/api/health")
 
48
  return {
49
  "status": "ok",
50
  "services": service_status(),
51
+ "models": _model_status(),
52
  }
app/services/clap_detector.py CHANGED
@@ -95,6 +95,14 @@ class CLAPDetectorService:
95
  self._classifier_svm: Optional[object] = None
96
  self._initialized = False
97
 
 
 
 
 
 
 
 
 
98
  def _ensure_initialized(self) -> bool:
99
  """Initialize CLAP model on first call."""
100
  if self._initialized:
 
95
  self._classifier_svm: Optional[object] = None
96
  self._initialized = False
97
 
98
+ def status(self) -> dict:
99
+ """Whether the CLAP embedding model loaded (it initialises on first use);
100
+ without it the layer falls back to a spectral heuristic."""
101
+ return {
102
+ "initialized": self._initialized,
103
+ "embeddingModel": self._model is not None,
104
+ }
105
+
106
  def _ensure_initialized(self) -> bool:
107
  """Initialize CLAP model on first call."""
108
  if self._initialized:
app/services/inference_xai.py CHANGED
@@ -474,6 +474,18 @@ class XAIInferenceService:
474
  logger.error(f"Failed to load XAI service: {e}", exc_info=True)
475
  self.available = False
476
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  def predict(
478
  self,
479
  features: AudioFeatures,
 
474
  logger.error(f"Failed to load XAI service: {e}", exc_info=True)
475
  self.available = False
476
 
477
+ def status(self) -> Dict[str, Any]:
478
+ """What actually loaded, for /api/health."""
479
+ return {
480
+ "available": self.available,
481
+ "bestModel": self.training_results.get("_best_model") if isinstance(self.training_results, dict) else None,
482
+ "threshold": round(float(self.threshold), 6) if self.available else None,
483
+ "features": len(self.feature_cols) if self.available else 0,
484
+ "ensembleLoaded": len(self.ensemble_models),
485
+ "ensembleTotal": len(_ML_MODEL_FILES) + len(_DL_MODEL_FILES),
486
+ "shap": self.shap_explainer is not None,
487
+ }
488
+
489
  def predict(
490
  self,
491
  features: AudioFeatures,
app/services/meta_classifier.py CHANGED
@@ -83,6 +83,10 @@ class MetaClassifierService:
83
  self._initialized = False
84
  self._trained = False
85
 
 
 
 
 
86
  def _ensure_loaded(self) -> bool:
87
  """Load trained meta-classifier if available."""
88
  if self._initialized:
 
83
  self._initialized = False
84
  self._trained = False
85
 
86
+ def status(self) -> dict:
87
+ """Whether the trained stacking model is in use (it loads on first use)."""
88
+ return {"initialized": self._initialized, "trained": self._trained}
89
+
90
  def _ensure_loaded(self) -> bool:
91
  """Load trained meta-classifier if available."""
92
  if self._initialized:
app/services/wav2vec2_detector.py CHANGED
@@ -56,6 +56,14 @@ class Wav2Vec2DetectorService:
56
  self._initialized = False
57
  self._model_path = model_path or _MODEL_PATH
58
 
 
 
 
 
 
 
 
 
59
  def _ensure_loaded(self) -> bool:
60
  """Lazy-load model on first use."""
61
  if self._initialized:
 
56
  self._initialized = False
57
  self._model_path = model_path or _MODEL_PATH
58
 
59
+ def status(self) -> dict:
60
+ """Weights on disk / model in memory (it loads on first use)."""
61
+ return {
62
+ "weights": self._model_path.exists(),
63
+ "loaded": self._model is not None,
64
+ "initialized": self._initialized,
65
+ }
66
+
67
  def _ensure_loaded(self) -> bool:
68
  """Lazy-load model on first use."""
69
  if self._initialized:
tests/test_analysis_jobs.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the background job store and the confidence tiers (no model inference)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from app.services.analysis_jobs import FILE_STEPS, JobStore, Progress, STEP_DONE
8
+ from app.services.inference_xai import XAIInferenceService
9
+
10
+
11
+ def test_job_reports_steps_and_settles() -> None:
12
+ async def scenario() -> None:
13
+ store = JobStore()
14
+
15
+ async def work(progress: Progress):
16
+ progress.running("features", "vocals")
17
+ await asyncio.sleep(0.02)
18
+ progress.set("features", STEP_DONE)
19
+ return {"result": {"ok": True}, "warnings": [], "errors": []}
20
+
21
+ job = store.start("file", FILE_STEPS, work)
22
+ assert job.to_dict()["status"] == "running"
23
+ await asyncio.sleep(0.05)
24
+
25
+ snapshot = store.get(job.id).to_dict()
26
+ assert snapshot["status"] == "done"
27
+ steps = {s["id"]: s for s in snapshot["steps"]}
28
+ assert steps["features"]["state"] == "done" and "seconds" in steps["features"]
29
+ assert steps["vocals"]["state"] == "running"
30
+ assert steps["meta"]["state"] == "pending"
31
+
32
+ asyncio.run(scenario())
33
+
34
+
35
+ def test_failed_job_settles_as_error() -> None:
36
+ async def scenario() -> None:
37
+ store = JobStore()
38
+
39
+ async def boom(progress: Progress):
40
+ raise RuntimeError("boom")
41
+
42
+ job = store.start("file", FILE_STEPS, boom)
43
+ await asyncio.sleep(0.01)
44
+ assert job.status == "error"
45
+ assert job.response == {"result": None, "warnings": [], "errors": ["internal_error"]}
46
+
47
+ asyncio.run(scenario())
48
+
49
+
50
+ def test_confidence_band_is_measured_from_the_threshold() -> None:
51
+ svc = XAIInferenceService.__new__(XAIInferenceService)
52
+ svc.threshold = 0.431577
53
+ # Just above the threshold: an AI verdict, but barely.
54
+ assert svc._confidence_band(0.45).tier == "uncertain"
55
+ # Below 0.5 yet clearly under the threshold's human side? No — close to it.
56
+ assert svc._confidence_band(0.40).tier == "uncertain"
57
+ assert svc._confidence_band(0.97).tier == "very_strong"
58
+ assert svc._confidence_band(0.02).tier == "very_strong"
59
+ band = svc._confidence_band(0.431577)
60
+ assert band.margin == 0.0
tests/test_analyze.py CHANGED
@@ -105,10 +105,10 @@ def test_analyze_file_too_small(
105
  assert "file_too_small" in data["errors"]
106
 
107
 
108
- def test_analyze_file_fallback_on_bad_audio(
109
  client: TestClient,
110
  ) -> None:
111
- """Non-parseable audio falls back to preview model."""
112
  audio_content = b"\x00" * 2048
113
  fake_audio = io.BytesIO(audio_content)
114
  response = client.post(
@@ -120,13 +120,75 @@ def test_analyze_file_fallback_on_bad_audio(
120
  )
121
  assert response.status_code == 200
122
  data = response.json()
123
- assert data["result"] is not None
124
- result = data["result"]
125
- assert "isAIGenerated" in result
126
- assert "confidence" in result
127
- # Bad audio triggers fallback to preview
128
- assert result["analysisMode"] == "preview"
129
- assert result["decisionSource"] == "preview"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
 
132
  def _make_wav(
 
105
  assert "file_too_small" in data["errors"]
106
 
107
 
108
+ def test_analyze_file_bad_audio_is_an_error(
109
  client: TestClient,
110
  ) -> None:
111
+ """Undecodable audio returns an error, never an invented result."""
112
  audio_content = b"\x00" * 2048
113
  fake_audio = io.BytesIO(audio_content)
114
  response = client.post(
 
120
  )
121
  assert response.status_code == 200
122
  data = response.json()
123
+ assert data["result"] is None
124
+ assert data["errors"][0] in {"audio_decode_failed", "audio_silent", "audio_too_short"}
125
+
126
+
127
+ def test_analyze_accepts_audio_extension_with_video_mime(
128
+ client: TestClient,
129
+ ) -> None:
130
+ """A .webm labelled video/webm by the browser is audio, not an invalid type."""
131
+ response = client.post(
132
+ "/api/analyze",
133
+ data={"sourceType": "file"},
134
+ files={
135
+ "file": ("take.webm", io.BytesIO(b"\x1a" * 100), "video/webm"),
136
+ },
137
+ )
138
+ assert response.status_code == 200
139
+ data = response.json()
140
+ assert data["errors"] == ["file_too_small"]
141
+
142
+
143
+ def test_jobs_rejects_invalid_source_immediately(
144
+ client: TestClient,
145
+ ) -> None:
146
+ """Validation errors come back at once, without starting a job."""
147
+ response = client.post(
148
+ "/api/analyze/jobs",
149
+ data={"sourceType": "unknown"},
150
+ )
151
+ assert response.status_code == 200
152
+ data = response.json()
153
+ assert data["jobId"] is None
154
+ assert data["status"] == "error"
155
+ assert "invalid_source_type" in data["response"]["errors"]
156
+
157
+
158
+ def test_jobs_missing_url(
159
+ client: TestClient,
160
+ ) -> None:
161
+ response = client.post(
162
+ "/api/analyze/jobs",
163
+ data={"sourceType": "youtube"},
164
+ )
165
+ assert response.status_code == 200
166
+ assert "missing_url" in response.json()["response"]["errors"]
167
+
168
+
169
+ def test_jobs_unknown_id_is_404(
170
+ client: TestClient,
171
+ ) -> None:
172
+ response = client.get("/api/analyze/jobs/does-not-exist")
173
+ assert response.status_code == 404
174
+ assert response.json()["detail"]["code"] == "job_not_found"
175
+
176
+
177
+ def test_rate_limit_key_uses_forwarded_client() -> None:
178
+ """Behind the Space proxy each visitor gets their own bucket."""
179
+ from starlette.requests import Request
180
+
181
+ from app.routes.analyze import _client_ip
182
+
183
+ def make(headers):
184
+ return Request({
185
+ "type": "http",
186
+ "headers": [(k.encode(), v.encode()) for k, v in headers.items()],
187
+ "client": ("10.0.0.1", 1234),
188
+ })
189
+
190
+ assert _client_ip(make({"x-forwarded-for": "203.0.113.7, 10.0.0.1"})) == "203.0.113.7"
191
+ assert _client_ip(make({})) == "10.0.0.1"
192
 
193
 
194
  def _make_wav(
tests/test_health.py CHANGED
@@ -22,3 +22,13 @@ def test_health_endpoint(client: TestClient) -> None:
22
  data = response.json()
23
  assert data["status"] == "ok"
24
  assert "services" in data
 
 
 
 
 
 
 
 
 
 
 
22
  data = response.json()
23
  assert data["status"] == "ok"
24
  assert "services" in data
25
+
26
+
27
+ def test_health_reports_model_inventory(client: TestClient) -> None:
28
+ """Health says what actually loaded, not just that the process is up."""
29
+ data = client.get("/api/health").json()
30
+ models = data["models"]
31
+ assert "xai" in models and "available" in models["xai"]
32
+ assert models["xai"]["ensembleTotal"] == 11
33
+ assert "wav2vec2" in models and "clap" in models and "meta" in models
34
+ assert models["jobs"]["running"] >= 0