echoguard-api / app /routers /analysis.py
GitHub Actions
Automated backend deployment from GitHub Actions
96fd859
Raw
History Blame Contribute Delete
8.4 kB
"""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"],
}
@router.post(
"/analyze",
response_model=AnalysisResponse,
status_code=status.HTTP_200_OK,
responses={
413: {"model": ErrorResponse, "description": "File too large"},
415: {"model": ErrorResponse, "description": "Unsupported file type"},
422: {"model": ErrorResponse, "description": "Audio too long or unprocessable"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
summary="Analyze audio for deepfake detection",
description=(
"Upload a WAV or MP3 audio file for AI-powered deepfake detection analysis. "
"Maximum file size: 30 MB. Maximum duration: 5 minutes."
),
)
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