File size: 8,396 Bytes
96fd859
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
"""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