Rthur2003 commited on
Commit
7b35ef2
·
1 Parent(s): f4784fd

fix: enhance analyze endpoint with full audio analysis pipeline and vocal analysis support

Browse files
Files changed (1) hide show
  1. app/routes/analyze.py +235 -25
app/routes/analyze.py CHANGED
@@ -2,19 +2,39 @@
2
 
3
  Handles YouTube URLs and file uploads through a unified endpoint.
4
  Response format matches platform/hooks/analysisTypes.ts
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
 
9
  import time
10
  import uuid
11
  from collections import defaultdict
12
  from typing import Optional, List, Dict, Any, Literal
13
- from fastapi import APIRouter, File, UploadFile, Form, Request, Depends, HTTPException, status
 
 
 
14
  from pydantic import BaseModel, Field
15
 
16
  from ..services.youtube_analysis import YouTubeAnalysisService
17
  from ..services.preview_model import PreviewModel
 
 
 
 
 
 
 
 
 
18
  from ..services.logging_config import get_logger
19
 
20
  router = APIRouter()
@@ -33,9 +53,11 @@ async def _analyze_rate_limit(request: Request) -> None:
33
  now = time.time()
34
  cutoff = now - _ANALYZE_RATE_WINDOW
35
 
36
- # Evict stale IPs when map grows large
37
  if len(_analyze_rate_store) > _ANALYZE_MAX_IPS:
38
- stale = [ip for ip, ts in _analyze_rate_store.items() if all(t <= cutoff for t in ts)]
 
 
 
39
  for ip in stale:
40
  del _analyze_rate_store[ip]
41
 
@@ -43,10 +65,15 @@ async def _analyze_rate_limit(request: Request) -> None:
43
  if len(hits) >= _ANALYZE_RATE_MAX:
44
  raise HTTPException(
45
  status_code=status.HTTP_429_TOO_MANY_REQUESTS,
46
- detail={"code": "rate_limit_exceeded", "message": "Too many analysis requests. Please wait before trying again."}
 
 
 
47
  )
48
  hits.append(now)
49
  _analyze_rate_store[client_ip] = hits
 
 
50
  youtube_service = YouTubeAnalysisService()
51
  preview_model = PreviewModel()
52
 
@@ -55,6 +82,24 @@ preview_model = PreviewModel()
55
  # Response Models - Platform Uyumlu
56
  # ============================================
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  class AnalysisFeatures(BaseModel):
59
  """Audio feature analysis results."""
60
 
@@ -74,17 +119,18 @@ class AudioInfo(BaseModel):
74
 
75
 
76
  class AnalysisResult(BaseModel):
77
- """Platform/hooks/analysisTypes.ts ile uyumlu."""
78
 
79
  isAIGenerated: bool
80
  confidence: float = Field(..., ge=0.0, le=1.0)
81
  processingTime: float
82
  modelVersion: str
83
- decisionSource: Literal["music_ai", "ses_analizi", "preview"]
84
  analysisMode: Literal["production", "preview"]
85
  source: Dict[str, Any]
86
  features: AnalysisFeatures
87
  audioInfo: AudioInfo
 
88
 
89
 
90
  class AnalyzeResponse(BaseModel):
@@ -95,7 +141,11 @@ class AnalyzeResponse(BaseModel):
95
  errors: List[str] = Field(default_factory=list)
96
 
97
 
98
- @router.post("/api/analyze", response_model=AnalyzeResponse, dependencies=[Depends(_analyze_rate_limit)])
 
 
 
 
99
  async def analyze(
100
  sourceType: str = Form(...),
101
  url: Optional[str] = Form(None),
@@ -131,12 +181,18 @@ async def analyze(
131
  async def _analyze_youtube(
132
  request_id: str, url: str, start_time: float
133
  ) -> AnalyzeResponse:
134
- """Analyze YouTube URL."""
135
  try:
136
- yt_result = await youtube_service.analyze(url, include_raw=False)
137
  processing_time = round(time.monotonic() - start_time, 3)
138
 
139
- # Feature'ları preview model'den al
 
 
 
 
 
 
140
  preview = preview_model.analyze(yt_result.source.video_id)
141
  features_data = preview.get("features", {})
142
 
@@ -156,9 +212,15 @@ async def _analyze_youtube(
156
  "startTimeSec": yt_result.source.start_time_sec
157
  },
158
  features=AnalysisFeatures(
159
- spectralRegularity=features_data.get("spectral_regularity", 0.5),
160
- temporalPatterns=features_data.get("temporal_patterns", 0.5),
161
- harmonicStructure=features_data.get("harmonic_structure", 0.5),
 
 
 
 
 
 
162
  artificialIndicators=yt_result.summary.indicators
163
  ),
164
  audioInfo=AudioInfo(
@@ -183,8 +245,20 @@ async def _analyze_youtube(
183
  return AnalyzeResponse(errors=["youtube_analysis_failed"])
184
 
185
 
186
- async def _analyze_file(request_id: str, file: UploadFile, start_time: float) -> AnalyzeResponse:
187
- """Dosya analizi."""
 
 
 
 
 
 
 
 
 
 
 
 
188
  content_type = file.content_type or ""
189
  if not content_type.startswith("audio/"):
190
  return AnalyzeResponse(errors=["invalid_file_type"])
@@ -198,9 +272,131 @@ async def _analyze_file(request_id: str, file: UploadFile, start_time: float) ->
198
  if file_size < 1024:
199
  return AnalyzeResponse(errors=["file_too_small"])
200
 
201
- logger.info(f"[{request_id}] File: {file.filename} ({file_size / 1024 / 1024:.2f}MB)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
- # Preview model ile analiz
204
  fingerprint = f"file_{file.filename}_{file_size}"
205
  preview = preview_model.analyze(fingerprint)
206
  features_data = preview.get("features", {})
@@ -217,20 +413,34 @@ async def _analyze_file(request_id: str, file: UploadFile, start_time: float) ->
217
  "kind": "file",
218
  "fileName": file.filename or "unknown",
219
  "fileSizeBytes": file_size,
220
- "mimeType": content_type
221
  },
222
  features=AnalysisFeatures(
223
- spectralRegularity=features_data.get("spectral_regularity", 0.5),
224
- temporalPatterns=features_data.get("temporal_patterns", 0.5),
225
- harmonicStructure=features_data.get("harmonic_structure", 0.5),
226
- artificialIndicators=preview.get("indicators", [])
 
 
 
 
 
 
 
 
227
  ),
228
  audioInfo=AudioInfo(
229
  duration=0.0,
230
  sampleRate=44100,
231
  bitrate=128,
232
- format=content_type.replace("audio/", "")
233
- )
 
 
234
  )
235
 
236
- return AnalyzeResponse(result=result, warnings=[], errors=[])
 
 
 
 
 
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 io
17
  import time
18
  import uuid
19
  from collections import defaultdict
20
  from typing import Optional, List, Dict, Any, Literal
21
+ from fastapi import (
22
+ APIRouter, File, UploadFile, Form,
23
+ Request, Depends, HTTPException, status,
24
+ )
25
  from pydantic import BaseModel, Field
26
 
27
  from ..services.youtube_analysis import YouTubeAnalysisService
28
  from ..services.preview_model import PreviewModel
29
+ from ..services.feature_extractor import (
30
+ extract_features,
31
+ AudioFeatures,
32
+ )
33
+ from ..services.vocal_analyzer import (
34
+ analyze_vocals,
35
+ VocalFeatures,
36
+ )
37
+ from ..services.score_fusion import fuse_scores, FusionResult
38
  from ..services.logging_config import get_logger
39
 
40
  router = APIRouter()
 
53
  now = time.time()
54
  cutoff = now - _ANALYZE_RATE_WINDOW
55
 
 
56
  if len(_analyze_rate_store) > _ANALYZE_MAX_IPS:
57
+ stale = [
58
+ ip for ip, ts in _analyze_rate_store.items()
59
+ if all(t <= cutoff for t in ts)
60
+ ]
61
  for ip in stale:
62
  del _analyze_rate_store[ip]
63
 
 
65
  if len(hits) >= _ANALYZE_RATE_MAX:
66
  raise HTTPException(
67
  status_code=status.HTTP_429_TOO_MANY_REQUESTS,
68
+ detail={
69
+ "code": "rate_limit_exceeded",
70
+ "message": "Too many requests. Please wait.",
71
+ }
72
  )
73
  hits.append(now)
74
  _analyze_rate_store[client_ip] = hits
75
+
76
+
77
  youtube_service = YouTubeAnalysisService()
78
  preview_model = PreviewModel()
79
 
 
82
  # Response Models - Platform Uyumlu
83
  # ============================================
84
 
85
+ class VocalAnalysis(BaseModel):
86
+ """Vocal analysis results for the response."""
87
+
88
+ hasVocals: bool = False
89
+ vocalConfidence: float = 0.0
90
+ vocalAiScore: float = 0.0
91
+ pitchStabilityScore: float = 0.0
92
+ vibratoRegularityScore: float = 0.0
93
+ formantConsistencyScore: float = 0.0
94
+ breathPatternScore: float = 0.0
95
+ vocalTextureScore: float = 0.0
96
+ pitchMeanHz: float = 0.0
97
+ pitchStdCents: float = 0.0
98
+ vibratoRateHz: float = 0.0
99
+ vibratoExtentCents: float = 0.0
100
+ indicators: List[str] = Field(default_factory=list)
101
+
102
+
103
  class AnalysisFeatures(BaseModel):
104
  """Audio feature analysis results."""
105
 
 
119
 
120
 
121
  class AnalysisResult(BaseModel):
122
+ """Platform/hooks/analysisTypes.ts uyumlu."""
123
 
124
  isAIGenerated: bool
125
  confidence: float = Field(..., ge=0.0, le=1.0)
126
  processingTime: float
127
  modelVersion: str
128
+ decisionSource: str
129
  analysisMode: Literal["production", "preview"]
130
  source: Dict[str, Any]
131
  features: AnalysisFeatures
132
  audioInfo: AudioInfo
133
+ vocalAnalysis: Optional[VocalAnalysis] = None
134
 
135
 
136
  class AnalyzeResponse(BaseModel):
 
141
  errors: List[str] = Field(default_factory=list)
142
 
143
 
144
+ @router.post(
145
+ "/api/analyze",
146
+ response_model=AnalyzeResponse,
147
+ dependencies=[Depends(_analyze_rate_limit)],
148
+ )
149
  async def analyze(
150
  sourceType: str = Form(...),
151
  url: Optional[str] = Form(None),
 
181
  async def _analyze_youtube(
182
  request_id: str, url: str, start_time: float
183
  ) -> AnalyzeResponse:
184
+ """Analyze YouTube URL with full pipeline."""
185
  try:
186
+ yt_result = await youtube_service.analyze(url, include_raw=True)
187
  processing_time = round(time.monotonic() - start_time, 3)
188
 
189
+ # If download succeeded, we have audio to analyze
190
+ # For now, use fusion with external service results + preview features
191
+ # TODO: When YouTube pipeline returns audio path, run full extraction
192
+
193
+ # Use preview model for feature scores until
194
+ # YouTube pipeline returns raw audio for local
195
+ # extraction with full feature + vocal analysis
196
  preview = preview_model.analyze(yt_result.source.video_id)
197
  features_data = preview.get("features", {})
198
 
 
212
  "startTimeSec": yt_result.source.start_time_sec
213
  },
214
  features=AnalysisFeatures(
215
+ spectralRegularity=features_data.get(
216
+ "spectral_regularity", 0.5,
217
+ ),
218
+ temporalPatterns=features_data.get(
219
+ "temporal_patterns", 0.5,
220
+ ),
221
+ harmonicStructure=features_data.get(
222
+ "harmonic_structure", 0.5,
223
+ ),
224
  artificialIndicators=yt_result.summary.indicators
225
  ),
226
  audioInfo=AudioInfo(
 
245
  return AnalyzeResponse(errors=["youtube_analysis_failed"])
246
 
247
 
248
+ async def _analyze_file(
249
+ request_id: str,
250
+ file: UploadFile,
251
+ start_time: float,
252
+ ) -> AnalyzeResponse:
253
+ """
254
+ Full audio analysis pipeline for uploaded files.
255
+
256
+ Pipeline:
257
+ 1. Validate file
258
+ 2. Extract audio features (spectral, temporal, harmonic)
259
+ 3. Analyze vocals (pitch, vibrato, formant, breath)
260
+ 4. Fuse all scores into final result
261
+ """
262
  content_type = file.content_type or ""
263
  if not content_type.startswith("audio/"):
264
  return AnalyzeResponse(errors=["invalid_file_type"])
 
272
  if file_size < 1024:
273
  return AnalyzeResponse(errors=["file_too_small"])
274
 
275
+ mb = file_size / 1024 / 1024
276
+ logger.info(
277
+ f"[{request_id}] File: {file.filename} "
278
+ f"({mb:.2f}MB)"
279
+ )
280
+
281
+ warnings: List[str] = []
282
+
283
+ # ── Step 1: Feature extraction ────────────
284
+ try:
285
+ audio_bytes = io.BytesIO(content)
286
+ features: AudioFeatures = extract_features(audio_bytes)
287
+ logger.info(
288
+ f"[{request_id}] Features: "
289
+ f"S={features.spectral_regularity:.3f} "
290
+ f"T={features.temporal_patterns:.3f} "
291
+ f"H={features.harmonic_structure:.3f}"
292
+ )
293
+ except ValueError as e:
294
+ logger.warning(
295
+ f"[{request_id}] Feature extraction "
296
+ f"failed: {e}"
297
+ )
298
+ return _fallback_analysis(
299
+ request_id, file, content,
300
+ file_size, start_time, str(e),
301
+ )
302
+ except Exception as e:
303
+ logger.error(
304
+ f"[{request_id}] Feature extraction "
305
+ f"error: {e}", exc_info=True,
306
+ )
307
+ warnings.append("feature_extraction_partial")
308
+ return _fallback_analysis(
309
+ request_id, file, content,
310
+ file_size, start_time, str(e),
311
+ )
312
+
313
+ # ── Step 2: Vocal analysis ──────────────
314
+ vocals: Optional[VocalFeatures] = None
315
+ try:
316
+ audio_bytes_vocal = io.BytesIO(content)
317
+ vocals = analyze_vocals(audio_bytes_vocal)
318
+ if vocals.has_vocals:
319
+ logger.info(
320
+ f"[{request_id}] Vocals: "
321
+ f"ai={vocals.vocal_ai_score:.3f} "
322
+ f"pitch={vocals.pitch_std_cents:.1f}c"
323
+ )
324
+ else:
325
+ logger.info(f"[{request_id}] No vocals detected")
326
+ except Exception as e:
327
+ logger.warning(f"[{request_id}] Vocal analysis failed: {e}")
328
+ warnings.append("vocal_analysis_unavailable")
329
+
330
+ # ── Step 3: Score fusion ───────────────
331
+ fusion: FusionResult = fuse_scores(features, vocals)
332
+
333
+ processing_time = round(time.monotonic() - start_time, 3)
334
+ logger.info(
335
+ f"[{request_id}] Fusion: ai={fusion.is_ai_generated}, "
336
+ f"confidence={fusion.confidence}, source={fusion.decision_source}"
337
+ )
338
+
339
+ # ── Step 4: Build response ─────────────
340
+ vocal_response = None
341
+ if vocals and vocals.has_vocals:
342
+ vocal_response = VocalAnalysis(
343
+ hasVocals=True,
344
+ vocalConfidence=round(vocals.vocal_confidence, 3),
345
+ vocalAiScore=round(vocals.vocal_ai_score, 3),
346
+ pitchStabilityScore=round(vocals.pitch_stability_score, 3),
347
+ vibratoRegularityScore=round(vocals.vibrato_regularity_score, 3),
348
+ formantConsistencyScore=round(vocals.formant_consistency_score, 3),
349
+ breathPatternScore=round(vocals.breath_pattern_score, 3),
350
+ vocalTextureScore=round(vocals.vocal_texture_score, 3),
351
+ pitchMeanHz=round(vocals.pitch_mean_hz, 1),
352
+ pitchStdCents=round(vocals.pitch_std_cents, 1),
353
+ vibratoRateHz=round(vocals.vibrato_rate_hz, 2),
354
+ vibratoExtentCents=round(vocals.vibrato_extent_cents, 1),
355
+ indicators=vocals.indicators,
356
+ )
357
+
358
+ result = AnalysisResult(
359
+ isAIGenerated=fusion.is_ai_generated,
360
+ confidence=fusion.confidence,
361
+ processingTime=processing_time,
362
+ modelVersion=fusion.model_version,
363
+ decisionSource=fusion.decision_source,
364
+ analysisMode=fusion.analysis_mode,
365
+ source={
366
+ "kind": "file",
367
+ "fileName": file.filename or "unknown",
368
+ "fileSizeBytes": file_size,
369
+ "mimeType": content_type,
370
+ },
371
+ features=AnalysisFeatures(
372
+ spectralRegularity=fusion.spectral_regularity,
373
+ temporalPatterns=fusion.temporal_patterns,
374
+ harmonicStructure=fusion.harmonic_structure,
375
+ artificialIndicators=fusion.indicators,
376
+ ),
377
+ audioInfo=AudioInfo(
378
+ duration=round(features.duration_sec, 2),
379
+ sampleRate=features.sample_rate,
380
+ bitrate=128,
381
+ format=content_type.replace("audio/", ""),
382
+ ),
383
+ vocalAnalysis=vocal_response,
384
+ )
385
+
386
+ return AnalyzeResponse(result=result, warnings=warnings, errors=[])
387
+
388
+
389
+ def _fallback_analysis(
390
+ request_id: str,
391
+ file: UploadFile,
392
+ content: bytes,
393
+ file_size: int,
394
+ start_time: float,
395
+ reason: str,
396
+ ) -> AnalyzeResponse:
397
+ """Fallback to preview model when real analysis fails."""
398
+ logger.warning(f"[{request_id}] Falling back to preview model: {reason}")
399
 
 
400
  fingerprint = f"file_{file.filename}_{file_size}"
401
  preview = preview_model.analyze(fingerprint)
402
  features_data = preview.get("features", {})
 
413
  "kind": "file",
414
  "fileName": file.filename or "unknown",
415
  "fileSizeBytes": file_size,
416
+ "mimeType": file.content_type or "audio/unknown",
417
  },
418
  features=AnalysisFeatures(
419
+ spectralRegularity=features_data.get(
420
+ "spectral_regularity", 0.5,
421
+ ),
422
+ temporalPatterns=features_data.get(
423
+ "temporal_patterns", 0.5,
424
+ ),
425
+ harmonicStructure=features_data.get(
426
+ "harmonic_structure", 0.5,
427
+ ),
428
+ artificialIndicators=preview.get(
429
+ "indicators", [],
430
+ ),
431
  ),
432
  audioInfo=AudioInfo(
433
  duration=0.0,
434
  sampleRate=44100,
435
  bitrate=128,
436
+ format=(
437
+ file.content_type or "audio/unknown"
438
+ ).replace("audio/", ""),
439
+ ),
440
  )
441
 
442
+ return AnalyzeResponse(
443
+ result=result,
444
+ warnings=["analysis_fallback_preview", f"reason: {reason}"],
445
+ errors=[],
446
+ )