Spaces:
Sleeping
Sleeping
| """Audio analysis endpoint with file validation and mock response.""" | |
| import uuid | |
| import os | |
| import tempfile | |
| import random | |
| from datetime import datetime, timezone | |
| from fastapi import APIRouter, UploadFile, File, Request, status, HTTPException | |
| from fastapi.responses import JSONResponse | |
| import logging | |
| import soundfile as sf | |
| from app.state import model_info | |
| from app.services.detector import EnsembleDetector | |
| from app.services.forensics import ForensicsAnalyzer | |
| logger = logging.getLogger(__name__) | |
| from app.config import settings | |
| from app.models import AnalysisResponse, ErrorResponse | |
| from app.exceptions import ( | |
| FileTooLargeError, | |
| InvalidFileTypeError, | |
| AudioTooLongError, | |
| AudioProcessingError, | |
| ) | |
| router = APIRouter(tags=["Analysis"]) | |
| ALLOWED_EXTENSIONS = {".wav", ".mp3", ".m4a"} | |
| ALLOWED_CONTENT_TYPES = { | |
| "audio/wav", | |
| "audio/x-wav", | |
| "audio/wave", | |
| "audio/mpeg", | |
| "audio/mp3", | |
| "audio/m4a", | |
| "audio/mp4", | |
| "audio/x-m4a", | |
| "application/octet-stream", # fallback for some clients | |
| } | |
| MAX_FILE_SIZE_BYTES = settings.max_file_size_mb * 1024 * 1024 | |
| def _get_file_extension(filename: str | None) -> str: | |
| """Extract and normalize file extension.""" | |
| if not filename: | |
| return "" | |
| return os.path.splitext(filename)[1].lower() | |
| def _validate_file_type(filename: str | None, content_type: str | None) -> None: | |
| """Validate that the uploaded file is wav or mp3.""" | |
| ext = _get_file_extension(filename) | |
| if ext not in ALLOWED_EXTENSIONS: | |
| raise InvalidFileTypeError( | |
| file_type=ext or "unknown", | |
| allowed_types=sorted(ALLOWED_EXTENSIONS), | |
| ) | |
| def _validate_file_size(file_size: int) -> None: | |
| """Validate that the file does not exceed the size limit.""" | |
| if file_size > MAX_FILE_SIZE_BYTES: | |
| raise FileTooLargeError( | |
| file_size_mb=file_size / (1024 * 1024), | |
| max_size_mb=settings.max_file_size_mb, | |
| ) | |
| def _get_audio_duration(file_path: str, extension: str) -> float: | |
| """Get audio duration in seconds.""" | |
| try: | |
| import mutagen | |
| audio = mutagen.File(file_path) | |
| if audio is not None and audio.info is not None: | |
| return audio.info.length # mutagen returns seconds | |
| except Exception: | |
| pass | |
| try: | |
| import librosa | |
| return librosa.get_duration(path=file_path) | |
| except Exception as e: | |
| raise AudioProcessingError( | |
| reason=f"Could not read audio duration. Ensure it is a valid {extension} file. ({str(e)})" | |
| ) | |
| def _validate_duration(duration_seconds: float) -> None: | |
| """Validate that the audio duration does not exceed the limit.""" | |
| if duration_seconds > settings.max_duration_seconds: | |
| raise AudioTooLongError( | |
| duration_seconds=duration_seconds, | |
| max_seconds=settings.max_duration_seconds, | |
| ) | |
| def _generate_prediction( | |
| analysis_id: str, | |
| filename: str, | |
| file_size_bytes: int, | |
| processed_data: dict, | |
| detection_result, | |
| forensics_data: dict, | |
| timeline_segments: list | |
| ) -> dict: | |
| """Generate final response using the real DL detector results.""" | |
| explanation = "Analysis completed successfully." | |
| return { | |
| "id": analysis_id, | |
| "filename": filename, | |
| "duration_seconds": round(processed_data["duration"], 2), | |
| "file_size_bytes": file_size_bytes, | |
| "prediction": detection_result.prediction, | |
| "confidence": round(detection_result.confidence, 4), | |
| "human_probability": round(detection_result.human_probability, 4), | |
| "ai_probability": round(detection_result.ai_probability, 4), | |
| "forensics": forensics_data, | |
| "timeline": timeline_segments, | |
| "sample_rate": processed_data["sample_rate"], | |
| "channels": processed_data["channels"], | |
| "peak_amplitude": processed_data["peak_amplitude"], | |
| "waveform": processed_data["waveform"], | |
| "spectrogram_image": processed_data["spectrogram_image"], | |
| "processed_audio_path": processed_data["processed_audio_path"], | |
| } | |
| async def analyze_audio(file: UploadFile = File(..., description="Audio file (.wav or .mp3)")): | |
| """ | |
| Analyze an uploaded audio file for deepfake detection. | |
| """ | |
| if model_info.status != "ready": | |
| raise HTTPException( | |
| status_code=503, | |
| detail=f"DL model is not ready. Status: {model_info.status}" | |
| ) | |
| from app.utils.audio import AudioProcessor | |
| analysis_id = str(uuid.uuid4()) | |
| _validate_file_type(file.filename, file.content_type) | |
| try: | |
| content = await file.read() | |
| except Exception: | |
| raise AudioProcessingError(reason="Failed to read uploaded file.") | |
| file_size = len(content) | |
| _validate_file_size(file_size) | |
| ext = _get_file_extension(file.filename) | |
| tmp_path = None | |
| processed_data = None | |
| try: | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp: | |
| tmp.write(content) | |
| tmp_path = tmp.name | |
| duration = _get_audio_duration(tmp_path, ext) | |
| _validate_duration(duration) | |
| processor = AudioProcessor() | |
| cache_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "cache")) | |
| processed_data = processor.process(tmp_path, cache_dir, analysis_id) | |
| except (FileTooLargeError, InvalidFileTypeError, AudioTooLongError, AudioProcessingError): | |
| raise # Re-raise known validation errors | |
| except Exception as e: | |
| error_msg = str(e) | |
| if not error_msg or "NoBackendError" in str(type(e)): | |
| error_msg = "The audio file format is not supported or the file is corrupted. (Missing decoding backend)" | |
| raise AudioProcessingError(reason=error_msg) | |
| finally: | |
| if tmp_path and os.path.exists(tmp_path): | |
| try: | |
| os.unlink(tmp_path) | |
| except OSError: | |
| pass | |
| if not processed_data: | |
| raise AudioProcessingError(reason="Failed to process audio features.") | |
| try: | |
| waveform, sr = sf.read(processed_data["processed_audio_path"], dtype="float32") | |
| from app.services.detector import EnsembleDetector | |
| detector = EnsembleDetector( | |
| model_info.gary_model, model_info.gary_feature_extractor, | |
| model_info.bisher_model, model_info.bisher_feature_extractor, | |
| sample_rate=16000 | |
| ) | |
| detection_result = detector.analyze(waveform, sample_rate=sr) | |
| timeline_segments = detector.analyze_timeline(waveform, sample_rate=sr) | |
| forensics_data = ForensicsAnalyzer.analyze(waveform, sr, ai_probability=detection_result.ai_probability) | |
| logger.info( | |
| f"Analysis complete - ID: {analysis_id} | " | |
| f"Duration: {processed_data['duration']:.2f}s | " | |
| f"Inference Time: {detection_result.inference_time_ms:.2f}ms | " | |
| f"Prediction: {detection_result.prediction} | " | |
| f"Confidence: {detection_result.confidence:.4f} | " | |
| f"AI Prob: {detection_result.ai_probability:.4f} | " | |
| f"Human Prob: {detection_result.human_probability:.4f}" | |
| ) | |
| except Exception as e: | |
| logger.error(f"Detection failed: {str(e)}") | |
| raise AudioProcessingError(reason=f"Model inference failed: {str(e)}") | |
| result = _generate_prediction( | |
| analysis_id=analysis_id, | |
| filename=file.filename or "unknown", | |
| file_size_bytes=file_size, | |
| processed_data=processed_data, | |
| detection_result=detection_result, | |
| forensics_data=forensics_data, | |
| timeline_segments=timeline_segments | |
| ) | |
| return result | |