GitHub Actions commited on
Commit
96fd859
·
0 Parent(s):

Automated backend deployment from GitHub Actions

Browse files
.env.example ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Server Configuration
2
+ API_HOST=0.0.0.0
3
+ API_PORT=8000
4
+ DEBUG=true
5
+
6
+ # CORS
7
+ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
8
+
9
+ # Model Configuration (future)
10
+ MODEL_PATH=../models/
11
+ MODEL_NAME=echoguard_v1
12
+
13
+ # Database (future)
14
+ # DATABASE_URL=sqlite:///./echoguard.db
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ gcc \
8
+ ffmpeg \
9
+ libsndfile1 \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Install Python dependencies
13
+ COPY requirements.txt .
14
+ RUN pip install --no-cache-dir -r requirements.txt
15
+
16
+ # Create a non-root user for Hugging Face Spaces security
17
+ RUN useradd -m -u 1000 user
18
+ USER user
19
+ ENV HOME=/home/user \
20
+ PATH=/home/user/.local/bin:$PATH
21
+
22
+ WORKDIR $HOME/app
23
+
24
+ # Copy application code
25
+ COPY --chown=user . $HOME/app
26
+
27
+ # Expose port (Hugging Face Spaces default is 7860)
28
+ EXPOSE 7860
29
+
30
+ # Run the application
31
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: EchoGuard Backend API
3
+ colorFrom: blue
4
+ colorTo: green
5
+ sdk: docker
6
+ pinned: false
7
+ ---
8
+
9
+ # EchoGuard Backend API
10
+
11
+ This is the deep learning backend for the EchoGuard application.
12
+ It processes audio files to detect deepfakes using Wav2Vec2 models and DSP forensics.
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # EchoGuard Backend
app/config.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from pydantic_settings import BaseSettings
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+
8
+
9
+ class Settings(BaseSettings):
10
+ """Application settings loaded from environment variables."""
11
+
12
+ api_host: str = "0.0.0.0"
13
+ api_port: int = 8000
14
+ debug: bool = True
15
+ cors_origins: str = "*"
16
+ model_path: str = "../models/"
17
+ model_name: str = "echoguard_v1"
18
+
19
+ # Upload limits
20
+ max_file_size_mb: int = 30
21
+ max_duration_seconds: int = 300 # 5 minutes
22
+
23
+ # Storage
24
+ upload_dir: str = "./uploads"
25
+
26
+ class Config:
27
+ env_file = ".env"
28
+ case_sensitive = False
29
+ protected_namespaces = ("settings_",)
30
+
31
+
32
+ settings = Settings()
app/exceptions.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom exception classes for the EchoGuard API."""
2
+
3
+ from fastapi import HTTPException, status
4
+
5
+
6
+ class FileTooLargeError(HTTPException):
7
+ """Raised when uploaded file exceeds the maximum size limit."""
8
+
9
+ def __init__(self, file_size_mb: float, max_size_mb: int):
10
+ super().__init__(
11
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
12
+ detail=f"File size ({file_size_mb:.1f} MB) exceeds maximum allowed size ({max_size_mb} MB).",
13
+ )
14
+
15
+
16
+ class InvalidFileTypeError(HTTPException):
17
+ """Raised when uploaded file has an unsupported format."""
18
+
19
+ def __init__(self, file_type: str, allowed_types: list[str]):
20
+ super().__init__(
21
+ status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
22
+ detail=f"File type '{file_type}' is not supported. Allowed types: {', '.join(allowed_types)}.",
23
+ )
24
+
25
+
26
+ class AudioTooLongError(HTTPException):
27
+ """Raised when audio duration exceeds the maximum limit."""
28
+
29
+ def __init__(self, duration_seconds: float, max_seconds: int):
30
+ max_minutes = max_seconds / 60
31
+ duration_minutes = duration_seconds / 60
32
+ super().__init__(
33
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
34
+ detail=f"Audio duration ({duration_minutes:.1f} min) exceeds maximum allowed duration ({max_minutes:.0f} min).",
35
+ )
36
+
37
+
38
+ class AudioProcessingError(HTTPException):
39
+ """Raised when audio file cannot be read or processed."""
40
+
41
+ def __init__(self, reason: str = "Unknown error"):
42
+ super().__init__(
43
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
44
+ detail=f"Failed to process audio file: {reason}",
45
+ )
app/main.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """EchoGuard API — FastAPI application entry point."""
2
+
3
+ import time
4
+ import logging
5
+ from contextlib import asynccontextmanager
6
+
7
+ from fastapi import FastAPI, Request, status
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.responses import JSONResponse
10
+ from fastapi.exceptions import RequestValidationError
11
+
12
+ from app.config import settings
13
+ from app.routers import analysis
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _startup_time = time.time()
18
+
19
+ from app.state import model_info
20
+
21
+ @asynccontextmanager
22
+ async def lifespan(app: FastAPI):
23
+ model_info.status = "loading"
24
+
25
+ try:
26
+ from transformers import AutoModelForAudioClassification, AutoFeatureExtractor
27
+
28
+ model_name_gary = "garystafford/wav2vec2-deepfake-voice-detector"
29
+ model_info.gary_feature_extractor = AutoFeatureExtractor.from_pretrained(model_name_gary)
30
+ model_info.gary_model = AutoModelForAudioClassification.from_pretrained(model_name_gary)
31
+
32
+ model_name_bisher = "Bisher/wav2vec2_ASV_deepfake_audio_detection"
33
+ model_info.bisher_feature_extractor = AutoFeatureExtractor.from_pretrained(model_name_bisher)
34
+ model_info.bisher_model = AutoModelForAudioClassification.from_pretrained(model_name_bisher)
35
+
36
+ model_info.status = "ready"
37
+ logger.info("Loaded both DL models for ensemble detection.")
38
+ except Exception as e:
39
+ model_info.status = "failed"
40
+ model_info.error = str(e)
41
+ logger.error(f"Failed to load DL models: {e}")
42
+
43
+ yield
44
+
45
+ model_info.gary_feature_extractor = None
46
+ model_info.gary_model = None
47
+ model_info.bisher_feature_extractor = None
48
+ model_info.bisher_model = None
49
+
50
+ app = FastAPI(
51
+ title="EchoGuard API",
52
+ description=(
53
+ "AI-powered deepfake audio detection API. "
54
+ "Upload WAV or MP3 files for analysis. "
55
+ "Maximum file size: 30 MB. Maximum duration: 5 minutes."
56
+ ),
57
+ version="0.1.0",
58
+ docs_url="/api/docs",
59
+ redoc_url="/api/redoc",
60
+ openapi_url="/api/openapi.json",
61
+ lifespan=lifespan,
62
+ )
63
+
64
+ origins = [origin.strip() for origin in settings.cors_origins.split(",")]
65
+
66
+ app.add_middleware(
67
+ CORSMiddleware,
68
+ allow_origins=origins,
69
+ allow_credentials=True,
70
+ allow_methods=["*"],
71
+ allow_headers=["*"],
72
+ )
73
+
74
+
75
+
76
+ @app.exception_handler(RequestValidationError)
77
+ async def validation_exception_handler(request: Request, exc: RequestValidationError):
78
+ """Handle Pydantic/FastAPI validation errors with a clean JSON response."""
79
+ errors = exc.errors()
80
+ detail = "; ".join(
81
+ f"{err.get('loc', ['unknown'])[-1]}: {err.get('msg', 'validation error')}"
82
+ for err in errors
83
+ )
84
+ return JSONResponse(
85
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
86
+ content={
87
+ "error": "validation_error",
88
+ "detail": detail,
89
+ "status_code": 422,
90
+ },
91
+ )
92
+
93
+
94
+ @app.exception_handler(Exception)
95
+ async def general_exception_handler(request: Request, exc: Exception):
96
+ """Catch-all handler for unexpected exceptions."""
97
+ return JSONResponse(
98
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
99
+ content={
100
+ "error": "internal_server_error",
101
+ "detail": "An unexpected error occurred. Please try again later.",
102
+ "status_code": 500,
103
+ },
104
+ )
105
+
106
+
107
+
108
+ app.include_router(analysis.router, prefix="/api")
109
+
110
+ @app.get(
111
+ "/",
112
+ summary="API Root",
113
+ description="Returns basic information about the EchoGuard API.",
114
+ tags=["System"],
115
+ )
116
+ async def root():
117
+ """Root endpoint providing basic API information."""
118
+ return {
119
+ "name": "EchoGuard Deepfake Detection API",
120
+ "version": "0.1.0",
121
+ "status": "online",
122
+ "documentation": "/api/docs",
123
+ "endpoints": {
124
+ "health_check": "/api/health",
125
+ "analyze_audio": "/api/analyze"
126
+ }
127
+ }
128
+
129
+ @app.get(
130
+ "/api/health",
131
+ summary="Health check",
132
+ description="Returns the current health status and uptime of the EchoGuard API.",
133
+ tags=["System"],
134
+ )
135
+ async def health_check():
136
+ """Health check endpoint."""
137
+ return {
138
+ "status": "healthy" if model_info.status == "ready" else "degraded",
139
+ "service": "EchoGuard API",
140
+ "version": "0.1.0",
141
+ "uptime_seconds": round(time.time() - _startup_time, 2),
142
+ "model_status": model_info.status,
143
+ "model_error": model_info.error
144
+ }
app/models.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models for API request/response schemas."""
2
+
3
+ from pydantic import BaseModel, Field
4
+ from typing import Optional, List, Dict, Union
5
+ from enum import Enum
6
+
7
+
8
+ class PredictionLabel(str, Enum):
9
+ """Possible prediction outcomes."""
10
+ AI_GENERATED = "AI Generated"
11
+ HUMAN = "Human"
12
+ UNCERTAIN = "Uncertain"
13
+
14
+ class MetricDetail(BaseModel):
15
+ score: int
16
+ confidence: str
17
+ reason: str
18
+
19
+ class ForensicMetrics(BaseModel):
20
+ voice_naturalness: Union[int, MetricDetail]
21
+ audio_quality: Union[int, MetricDetail]
22
+ characteristics: List[str]
23
+ advanced: Dict[str, str]
24
+
25
+ class TimelineSegment(BaseModel):
26
+ start: float
27
+ end: float
28
+ label: str
29
+ human_probability: float
30
+ ai_probability: float
31
+ class AnalysisResponse(BaseModel):
32
+ """Response schema for the /analyze endpoint."""
33
+ id: str = Field(..., description="Unique analysis ID")
34
+ filename: str = Field(..., description="Original uploaded filename")
35
+ duration_seconds: float = Field(..., description="Audio duration in seconds")
36
+ file_size_bytes: int = Field(..., description="File size in bytes")
37
+
38
+ # DL Preprocessing Data
39
+ sample_rate: int = Field(..., description="Audio sample rate in Hz")
40
+ channels: int = Field(..., description="Number of audio channels (1 for mono)")
41
+ peak_amplitude: float = Field(..., description="Peak amplitude after normalization")
42
+ waveform: list[float] = Field(..., description="Downsampled waveform array (max 500 points)")
43
+ spectrogram_image: str = Field(..., description="Base64 encoded PNG of the Mel Spectrogram")
44
+ processed_audio_path: str = Field(..., description="Path to the cached processed WAV file")
45
+
46
+ # Prediction
47
+ prediction: str = Field(..., description="Prediction label: 'AI Generated', 'Human', or 'Uncertain'")
48
+ confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence score between 0 and 1")
49
+ human_probability: float = Field(..., description="Human probability")
50
+ ai_probability: float = Field(..., description="AI probability")
51
+
52
+ # Forensics
53
+ forensics: ForensicMetrics = Field(..., description="Simplified forensics metrics")
54
+ timeline: List[TimelineSegment] = Field(..., description="1-second chunk analysis")
55
+
56
+ model_config = {
57
+ "json_schema_extra": {
58
+ "examples": [
59
+ {
60
+ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
61
+ "filename": "suspicious_call.wav",
62
+ "duration_seconds": 12.5,
63
+ "file_size_bytes": 1024000,
64
+ "sample_rate": 16000,
65
+ "channels": 1,
66
+ "peak_amplitude": 0.85,
67
+ "waveform": [0.12, -0.23, 0.45],
68
+ "spectrogram_image": "data:image/png;base64,iVBORw0KGgo...",
69
+ "processed_audio_path": "backend/cache/a1b2c3d4_processed.wav",
70
+ "prediction": "AI Generated",
71
+ "confidence": 0.92,
72
+ "human_probability": 0.08,
73
+ "ai_probability": 0.92,
74
+ "forensics": {
75
+ "voice_naturalness": 40,
76
+ "audio_quality": 85,
77
+ "speech_stability": 92,
78
+ "characteristics": ["⚠ Limited voice variation detected"],
79
+ "advanced": {"Mean Pitch (Hz)": "120.5"}
80
+ },
81
+ "timeline": [
82
+ {"start": 0.0, "end": 1.0, "label": "Suspicious", "human_probability": 0.1, "ai_probability": 0.9}
83
+ ]
84
+ }
85
+ ]
86
+ }
87
+ }
88
+
89
+
90
+ class HealthResponse(BaseModel):
91
+ """Response schema for the /health endpoint."""
92
+ status: str
93
+ service: str
94
+ version: str
95
+ uptime_seconds: float
96
+
97
+
98
+ class ErrorResponse(BaseModel):
99
+ """Standard error response schema."""
100
+ error: str = Field(..., description="Error type identifier")
101
+ detail: str = Field(..., description="Human-readable error description")
102
+ status_code: int = Field(..., description="HTTP status code")
app/routers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # EchoGuard Routers
app/routers/analysis.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio analysis endpoint with file validation and mock response."""
2
+
3
+ import uuid
4
+ import os
5
+ import tempfile
6
+ import random
7
+ from datetime import datetime, timezone
8
+
9
+ from fastapi import APIRouter, UploadFile, File, Request, status, HTTPException
10
+ from fastapi.responses import JSONResponse
11
+ import logging
12
+ import soundfile as sf
13
+
14
+ from app.state import model_info
15
+ from app.services.detector import EnsembleDetector
16
+ from app.services.forensics import ForensicsAnalyzer
17
+ logger = logging.getLogger(__name__)
18
+
19
+ from app.config import settings
20
+ from app.models import AnalysisResponse, ErrorResponse
21
+ from app.exceptions import (
22
+ FileTooLargeError,
23
+ InvalidFileTypeError,
24
+ AudioTooLongError,
25
+ AudioProcessingError,
26
+ )
27
+
28
+ router = APIRouter(tags=["Analysis"])
29
+
30
+ ALLOWED_EXTENSIONS = {".wav", ".mp3", ".m4a"}
31
+ ALLOWED_CONTENT_TYPES = {
32
+ "audio/wav",
33
+ "audio/x-wav",
34
+ "audio/wave",
35
+ "audio/mpeg",
36
+ "audio/mp3",
37
+ "audio/m4a",
38
+ "audio/mp4",
39
+ "audio/x-m4a",
40
+ "application/octet-stream", # fallback for some clients
41
+ }
42
+
43
+ MAX_FILE_SIZE_BYTES = settings.max_file_size_mb * 1024 * 1024
44
+
45
+
46
+ def _get_file_extension(filename: str | None) -> str:
47
+ """Extract and normalize file extension."""
48
+ if not filename:
49
+ return ""
50
+ return os.path.splitext(filename)[1].lower()
51
+
52
+
53
+ def _validate_file_type(filename: str | None, content_type: str | None) -> None:
54
+ """Validate that the uploaded file is wav or mp3."""
55
+ ext = _get_file_extension(filename)
56
+ if ext not in ALLOWED_EXTENSIONS:
57
+ raise InvalidFileTypeError(
58
+ file_type=ext or "unknown",
59
+ allowed_types=sorted(ALLOWED_EXTENSIONS),
60
+ )
61
+
62
+
63
+ def _validate_file_size(file_size: int) -> None:
64
+ """Validate that the file does not exceed the size limit."""
65
+ if file_size > MAX_FILE_SIZE_BYTES:
66
+ raise FileTooLargeError(
67
+ file_size_mb=file_size / (1024 * 1024),
68
+ max_size_mb=settings.max_file_size_mb,
69
+ )
70
+
71
+
72
+ def _get_audio_duration(file_path: str, extension: str) -> float:
73
+ """Get audio duration in seconds."""
74
+ try:
75
+ import mutagen
76
+ audio = mutagen.File(file_path)
77
+ if audio is not None and audio.info is not None:
78
+ return audio.info.length # mutagen returns seconds
79
+ except Exception:
80
+ pass
81
+
82
+ try:
83
+ import librosa
84
+ return librosa.get_duration(path=file_path)
85
+ except Exception as e:
86
+ raise AudioProcessingError(
87
+ reason=f"Could not read audio duration. Ensure it is a valid {extension} file. ({str(e)})"
88
+ )
89
+
90
+
91
+ def _validate_duration(duration_seconds: float) -> None:
92
+ """Validate that the audio duration does not exceed the limit."""
93
+ if duration_seconds > settings.max_duration_seconds:
94
+ raise AudioTooLongError(
95
+ duration_seconds=duration_seconds,
96
+ max_seconds=settings.max_duration_seconds,
97
+ )
98
+
99
+
100
+ def _generate_prediction(
101
+ analysis_id: str,
102
+ filename: str,
103
+ file_size_bytes: int,
104
+ processed_data: dict,
105
+ detection_result,
106
+ forensics_data: dict,
107
+ timeline_segments: list
108
+ ) -> dict:
109
+ """Generate final response using the real DL detector results."""
110
+
111
+ explanation = "Analysis completed successfully."
112
+
113
+ return {
114
+ "id": analysis_id,
115
+ "filename": filename,
116
+ "duration_seconds": round(processed_data["duration"], 2),
117
+ "file_size_bytes": file_size_bytes,
118
+ "prediction": detection_result.prediction,
119
+ "confidence": round(detection_result.confidence, 4),
120
+ "human_probability": round(detection_result.human_probability, 4),
121
+ "ai_probability": round(detection_result.ai_probability, 4),
122
+ "forensics": forensics_data,
123
+ "timeline": timeline_segments,
124
+ "sample_rate": processed_data["sample_rate"],
125
+ "channels": processed_data["channels"],
126
+ "peak_amplitude": processed_data["peak_amplitude"],
127
+ "waveform": processed_data["waveform"],
128
+ "spectrogram_image": processed_data["spectrogram_image"],
129
+ "processed_audio_path": processed_data["processed_audio_path"],
130
+ }
131
+
132
+
133
+ @router.post(
134
+ "/analyze",
135
+ response_model=AnalysisResponse,
136
+ status_code=status.HTTP_200_OK,
137
+ responses={
138
+ 413: {"model": ErrorResponse, "description": "File too large"},
139
+ 415: {"model": ErrorResponse, "description": "Unsupported file type"},
140
+ 422: {"model": ErrorResponse, "description": "Audio too long or unprocessable"},
141
+ 500: {"model": ErrorResponse, "description": "Internal server error"},
142
+ },
143
+ summary="Analyze audio for deepfake detection",
144
+ description=(
145
+ "Upload a WAV or MP3 audio file for AI-powered deepfake detection analysis. "
146
+ "Maximum file size: 30 MB. Maximum duration: 5 minutes."
147
+ ),
148
+ )
149
+ async def analyze_audio(file: UploadFile = File(..., description="Audio file (.wav or .mp3)")):
150
+ """
151
+ Analyze an uploaded audio file for deepfake detection.
152
+ """
153
+ if model_info.status != "ready":
154
+ raise HTTPException(
155
+ status_code=503,
156
+ detail=f"DL model is not ready. Status: {model_info.status}"
157
+ )
158
+
159
+ from app.utils.audio import AudioProcessor
160
+
161
+ analysis_id = str(uuid.uuid4())
162
+
163
+ _validate_file_type(file.filename, file.content_type)
164
+
165
+ try:
166
+ content = await file.read()
167
+ except Exception:
168
+ raise AudioProcessingError(reason="Failed to read uploaded file.")
169
+
170
+ file_size = len(content)
171
+ _validate_file_size(file_size)
172
+
173
+ ext = _get_file_extension(file.filename)
174
+ tmp_path = None
175
+ processed_data = None
176
+
177
+ try:
178
+ with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
179
+ tmp.write(content)
180
+ tmp_path = tmp.name
181
+
182
+ duration = _get_audio_duration(tmp_path, ext)
183
+ _validate_duration(duration)
184
+
185
+ processor = AudioProcessor()
186
+ cache_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "cache"))
187
+ processed_data = processor.process(tmp_path, cache_dir, analysis_id)
188
+
189
+ except (FileTooLargeError, InvalidFileTypeError, AudioTooLongError, AudioProcessingError):
190
+ raise # Re-raise known validation errors
191
+ except Exception as e:
192
+ error_msg = str(e)
193
+ if not error_msg or "NoBackendError" in str(type(e)):
194
+ error_msg = "The audio file format is not supported or the file is corrupted. (Missing decoding backend)"
195
+ raise AudioProcessingError(reason=error_msg)
196
+ finally:
197
+ if tmp_path and os.path.exists(tmp_path):
198
+ try:
199
+ os.unlink(tmp_path)
200
+ except OSError:
201
+ pass
202
+
203
+ if not processed_data:
204
+ raise AudioProcessingError(reason="Failed to process audio features.")
205
+
206
+ try:
207
+ waveform, sr = sf.read(processed_data["processed_audio_path"], dtype="float32")
208
+
209
+ from app.services.detector import EnsembleDetector
210
+ detector = EnsembleDetector(
211
+ model_info.gary_model, model_info.gary_feature_extractor,
212
+ model_info.bisher_model, model_info.bisher_feature_extractor,
213
+ sample_rate=16000
214
+ )
215
+ detection_result = detector.analyze(waveform, sample_rate=sr)
216
+
217
+ timeline_segments = detector.analyze_timeline(waveform, sample_rate=sr)
218
+
219
+ forensics_data = ForensicsAnalyzer.analyze(waveform, sr, ai_probability=detection_result.ai_probability)
220
+
221
+ logger.info(
222
+ f"Analysis complete - ID: {analysis_id} | "
223
+ f"Duration: {processed_data['duration']:.2f}s | "
224
+ f"Inference Time: {detection_result.inference_time_ms:.2f}ms | "
225
+ f"Prediction: {detection_result.prediction} | "
226
+ f"Confidence: {detection_result.confidence:.4f} | "
227
+ f"AI Prob: {detection_result.ai_probability:.4f} | "
228
+ f"Human Prob: {detection_result.human_probability:.4f}"
229
+ )
230
+
231
+ except Exception as e:
232
+ logger.error(f"Detection failed: {str(e)}")
233
+ raise AudioProcessingError(reason=f"Model inference failed: {str(e)}")
234
+
235
+ result = _generate_prediction(
236
+ analysis_id=analysis_id,
237
+ filename=file.filename or "unknown",
238
+ file_size_bytes=file_size,
239
+ processed_data=processed_data,
240
+ detection_result=detection_result,
241
+ forensics_data=forensics_data,
242
+ timeline_segments=timeline_segments
243
+ )
244
+
245
+ return result
app/services/detector.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import torch
3
+ import numpy as np
4
+ from dataclasses import dataclass
5
+
6
+ @dataclass
7
+ class DetectionResult:
8
+ human_probability: float
9
+ ai_probability: float
10
+ prediction: str
11
+ confidence: float
12
+ inference_time_ms: float
13
+
14
+ class EnsembleDetector:
15
+ """
16
+ Runs inference on multiple deepfake detection models and aggregates the results.
17
+ Specifically uses a 'Max-Ensemble' strategy: The final AI probability is the
18
+ maximum AI probability across all models. This ensures robustness against both
19
+ standard synthetic voices and SOTA deepfakes (like ElevenLabs).
20
+ """
21
+ def __init__(self, gary_model, gary_fe, bisher_model, bisher_fe, sample_rate: int = 16000):
22
+ self.gary_model = gary_model
23
+ self.gary_fe = gary_fe
24
+ self.bisher_model = bisher_model
25
+ self.bisher_fe = bisher_fe
26
+ self.target_sample_rate = sample_rate
27
+
28
+ def _get_probs(self, model, fe, waveform) -> tuple[float, float]:
29
+ """Returns (human_prob, ai_prob) for a single model."""
30
+ inputs = fe(
31
+ waveform,
32
+ sampling_rate=self.target_sample_rate,
33
+ return_tensors="pt",
34
+ padding=True
35
+ )
36
+ with torch.no_grad():
37
+ logits = model(**inputs).logits
38
+ probabilities = torch.nn.functional.softmax(logits, dim=-1)
39
+
40
+ id2label = model.config.id2label
41
+ fake_idx = 1
42
+ for idx, label in id2label.items():
43
+ l_lower = label.lower()
44
+ if "fake" in l_lower or "spoof" in l_lower or "ai" in l_lower:
45
+ fake_idx = idx
46
+ break
47
+
48
+ human_idx = 1 if fake_idx == 0 else 0
49
+ probs = probabilities[0].tolist()
50
+ return probs[human_idx], probs[fake_idx]
51
+
52
+ def analyze(self, waveform: np.ndarray, sample_rate: int = 16000) -> DetectionResult:
53
+ if sample_rate != self.target_sample_rate:
54
+ raise ValueError(f"Detector requires sample rate of {self.target_sample_rate}Hz. Got {sample_rate}Hz.")
55
+
56
+ start_time = time.time()
57
+
58
+ # Cap waveform to 10 seconds to prevent OOM and quadratic attention lag
59
+ max_samples = 10 * self.target_sample_rate
60
+ if len(waveform) > max_samples:
61
+ waveform = waveform[:max_samples]
62
+
63
+ try:
64
+ # 1. Run Garystafford model
65
+ g_hp, g_ap = self._get_probs(self.gary_model, self.gary_fe, waveform)
66
+
67
+ # 2. Run Bisher model
68
+ b_hp, b_ap = self._get_probs(self.bisher_model, self.bisher_fe, waveform)
69
+
70
+ # 3. Aggregate (Max-Ensemble for AI probability)
71
+ final_ai_prob = max(g_ap, b_ap)
72
+ final_human_prob = 1.0 - final_ai_prob
73
+
74
+ is_human = final_human_prob > final_ai_prob
75
+ prediction = "LIKELY HUMAN" if is_human else "LIKELY AI GENERATED"
76
+ confidence = final_human_prob if is_human else final_ai_prob
77
+
78
+ inference_time_ms = (time.time() - start_time) * 1000
79
+
80
+ return DetectionResult(
81
+ human_probability=final_human_prob,
82
+ ai_probability=final_ai_prob,
83
+ prediction=prediction,
84
+ confidence=confidence,
85
+ inference_time_ms=inference_time_ms
86
+ )
87
+
88
+ except Exception as e:
89
+ raise RuntimeError(f"Deepfake ensemble detection failed during inference: {str(e)}")
90
+
91
+ def _get_batch_probs(self, model, fe, chunks, batch_size=10) -> list[tuple[float, float]]:
92
+ """Returns list of (human_prob, ai_prob) for a batch of chunks."""
93
+ all_probs = []
94
+
95
+ # Determine the fake index dynamically once
96
+ id2label = model.config.id2label
97
+ fake_idx = 1
98
+ for idx, label in id2label.items():
99
+ l_lower = label.lower()
100
+ if "fake" in l_lower or "spoof" in l_lower or "ai" in l_lower:
101
+ fake_idx = idx
102
+ break
103
+ human_idx = 1 if fake_idx == 0 else 0
104
+
105
+ # Process in smaller batches to prevent OOM
106
+ for i in range(0, len(chunks), batch_size):
107
+ batch = chunks[i:i+batch_size]
108
+ inputs = fe(
109
+ batch,
110
+ sampling_rate=self.target_sample_rate,
111
+ return_tensors="pt",
112
+ padding=True
113
+ )
114
+ with torch.no_grad():
115
+ logits = model(**inputs).logits
116
+ probabilities = torch.nn.functional.softmax(logits, dim=-1)
117
+
118
+ probs_list = probabilities.tolist()
119
+ all_probs.extend([(p[human_idx], p[fake_idx]) for p in probs_list])
120
+
121
+ return all_probs
122
+
123
+ def analyze_timeline(self, waveform: np.ndarray, sample_rate: int = 16000) -> list:
124
+ if sample_rate != self.target_sample_rate:
125
+ raise ValueError(f"Detector requires sample rate of {self.target_sample_rate}Hz. Got {sample_rate}Hz.")
126
+
127
+ window_size = sample_rate # 1 second windows
128
+ segments = []
129
+ num_windows = len(waveform) // window_size
130
+
131
+ chunks = []
132
+ for i in range(num_windows):
133
+ start_sample = i * window_size
134
+ end_sample = start_sample + window_size
135
+ chunks.append(waveform[start_sample:end_sample])
136
+
137
+ has_remainder = False
138
+ if len(waveform) % window_size > int(window_size * 0.1):
139
+ start_sample = num_windows * window_size
140
+ chunks.append(waveform[start_sample:])
141
+ has_remainder = True
142
+
143
+ if not chunks:
144
+ return []
145
+
146
+ try:
147
+ g_probs = self._get_batch_probs(self.gary_model, self.gary_fe, chunks)
148
+ b_probs = self._get_batch_probs(self.bisher_model, self.bisher_fe, chunks)
149
+
150
+ for i in range(len(chunks)):
151
+ g_hp, g_ap = g_probs[i]
152
+ b_hp, b_ap = b_probs[i]
153
+
154
+ final_ap = max(g_ap, b_ap)
155
+ final_hp = 1.0 - final_ap
156
+
157
+ if final_hp > 0.70:
158
+ lbl = "Human-like"
159
+ elif final_ap > 0.70:
160
+ lbl = "Suspicious"
161
+ else:
162
+ lbl = "Neutral"
163
+
164
+ start_sec = float(i)
165
+ end_sec = float(i + 1)
166
+
167
+ if has_remainder and i == len(chunks) - 1:
168
+ end_sec = float(len(waveform) / sample_rate)
169
+
170
+ segments.append({
171
+ "start": start_sec,
172
+ "end": end_sec,
173
+ "label": lbl,
174
+ "human_probability": float(final_hp),
175
+ "ai_probability": float(final_ap)
176
+ })
177
+ except Exception as e:
178
+ for i in range(len(chunks)):
179
+ start_sec = float(i)
180
+ end_sec = float(i + 1) if not (has_remainder and i == len(chunks) - 1) else float(len(waveform) / sample_rate)
181
+ segments.append({
182
+ "start": start_sec,
183
+ "end": end_sec,
184
+ "label": "Neutral",
185
+ "human_probability": 0.5,
186
+ "ai_probability": 0.5
187
+ })
188
+
189
+ return segments
app/services/forensics.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import librosa
3
+
4
+ class ForensicsAnalyzer:
5
+ """
6
+ Independent Audio Forensics Engine.
7
+ Evaluates raw DSP features to compute Voice Naturalness and Audio Quality.
8
+ Completely independent of deep learning models and classifier predictions.
9
+ """
10
+
11
+ @staticmethod
12
+ def analyze(waveform: np.ndarray, sr: int, ai_probability: float = 0.0) -> dict:
13
+ # ai_probability is kept in the signature strictly for API compatibility.
14
+ # It is NEVER referenced or used in any score, characteristic, or calculation.
15
+
16
+ # --- 1. Multi-Window Analysis ---
17
+ window_duration = 5 * sr
18
+ if len(waveform) > 15 * sr:
19
+ # Extract 3 representative windows
20
+ w1 = waveform[:window_duration]
21
+ mid_start = len(waveform) // 2 - window_duration // 2
22
+ w2 = waveform[mid_start : mid_start + window_duration]
23
+ w3 = waveform[-window_duration:]
24
+ windows = [w1, w2, w3]
25
+ elif len(waveform) > window_duration:
26
+ w1 = waveform[:window_duration]
27
+ w2 = waveform[-window_duration:]
28
+ windows = [w1, w2]
29
+ else:
30
+ windows = [waveform]
31
+
32
+ metrics = {
33
+ "pitch_std": [],
34
+ "pitch_range": [],
35
+ "mean_pitch": [],
36
+ "pause_ratio": [],
37
+ "speech_ratio": [],
38
+ "sc_mean": [],
39
+ "sb_mean": [],
40
+ "rms_cons": [],
41
+ "zcr_cons": []
42
+ }
43
+
44
+ for w in windows:
45
+ if len(w) == 0:
46
+ continue
47
+
48
+ # --- Adaptive Pause & Speech Detection ---
49
+ # Using 20th percentile of RMS energy as adaptive baseline statistic
50
+ rms = librosa.feature.rms(y=w)[0]
51
+ if len(rms) > 0:
52
+ p20 = np.percentile(rms, 20)
53
+ p95 = np.percentile(rms, 95)
54
+ # Adaptive silence threshold: p20 + 10% of dynamic range
55
+ adaptive_threshold = p20 + 0.10 * max(0.0, p95 - p20)
56
+
57
+ pause_ratio = float(np.mean(rms < adaptive_threshold))
58
+ speech_ratio = 1.0 - pause_ratio
59
+
60
+ # RMS Consistency via percentile-based normalization
61
+ # If dynamic range or variance is smooth, consistency is high
62
+ rms_cv = np.std(rms) / (np.mean(rms) + 1e-6)
63
+ rms_cons = float(np.clip(100.0 * np.exp(-1.5 * rms_cv), 0.0, 100.0))
64
+ else:
65
+ adaptive_threshold = 0.0
66
+ pause_ratio = 1.0
67
+ speech_ratio = 0.0
68
+ rms_cons = 0.0
69
+
70
+ metrics["pause_ratio"].append(pause_ratio)
71
+ metrics["speech_ratio"].append(speech_ratio)
72
+ metrics["rms_cons"].append(rms_cons)
73
+
74
+ # --- Pitch Dynamics ---
75
+ try:
76
+ f0 = librosa.yin(w, fmin=librosa.note_to_hz('C2'), fmax=librosa.note_to_hz('C7'), sr=sr)
77
+ min_len = min(len(f0), len(rms))
78
+ f0_trimmed = f0[:min_len]
79
+ rms_trimmed = rms[:min_len]
80
+
81
+ # Filter strictly for active speech frames above adaptive threshold
82
+ valid_f0 = f0_trimmed[rms_trimmed >= adaptive_threshold]
83
+ if len(valid_f0) > 5:
84
+ f0_std = float(np.std(valid_f0))
85
+ f0_mean = float(np.mean(valid_f0))
86
+ # Pitch range: 90th percentile - 10th percentile to avoid spurious outlier jumps
87
+ f0_range = float(np.percentile(valid_f0, 90) - np.percentile(valid_f0, 10))
88
+
89
+ metrics["pitch_std"].append(f0_std)
90
+ metrics["pitch_range"].append(f0_range)
91
+ metrics["mean_pitch"].append(f0_mean)
92
+ else:
93
+ metrics["pitch_std"].append(0.0)
94
+ metrics["pitch_range"].append(0.0)
95
+ metrics["mean_pitch"].append(0.0)
96
+ except Exception:
97
+ metrics["pitch_std"].append(0.0)
98
+ metrics["pitch_range"].append(0.0)
99
+ metrics["mean_pitch"].append(0.0)
100
+
101
+ # --- Spectral & Temporal Quality ---
102
+ sc = librosa.feature.spectral_centroid(y=w, sr=sr)[0]
103
+ sb = librosa.feature.spectral_bandwidth(y=w, sr=sr)[0]
104
+ zcr = librosa.feature.zero_crossing_rate(y=w)[0]
105
+
106
+ metrics["sc_mean"].append(float(np.mean(sc)))
107
+ metrics["sb_mean"].append(float(np.mean(sb)))
108
+
109
+ # ZCR consistency: exponential decay normalization of zero-crossing variation
110
+ zcr_cv = np.std(zcr) / (np.mean(zcr) + 1e-6)
111
+ zcr_cons = float(np.clip(100.0 * np.exp(-1.0 * zcr_cv), 0.0, 100.0))
112
+ metrics["zcr_cons"].append(zcr_cons)
113
+
114
+ # Safely average all windowed metrics
115
+ avg_pitch_std = float(np.mean(metrics["pitch_std"])) if metrics["pitch_std"] else 0.0
116
+ avg_pitch_range = float(np.mean(metrics["pitch_range"])) if metrics["pitch_range"] else 0.0
117
+ avg_mean_pitch = float(np.mean(metrics["mean_pitch"])) if metrics["mean_pitch"] else 0.0
118
+ avg_pause_ratio = float(np.mean(metrics["pause_ratio"])) if metrics["pause_ratio"] else 0.0
119
+ avg_speech_ratio = float(np.mean(metrics["speech_ratio"])) if metrics["speech_ratio"] else 0.0
120
+ avg_sc_mean = float(np.mean(metrics["sc_mean"])) if metrics["sc_mean"] else 0.0
121
+ avg_sb_mean = float(np.mean(metrics["sb_mean"])) if metrics["sb_mean"] else 0.0
122
+ avg_rms_cons = float(np.mean(metrics["rms_cons"])) if metrics["rms_cons"] else 0.0
123
+ avg_zcr_cons = float(np.mean(metrics["zcr_cons"])) if metrics["zcr_cons"] else 0.0
124
+
125
+ # --- 2. PURE DSP SCORING: VOICE NATURALNESS ---
126
+ # Depends strictly on: pitch variation, pitch range, pause ratio
127
+ # Non-linear Gaussian/exponential normalization across full 0-100 spectrum
128
+
129
+ # 1) Pitch variation score (ideal conversational speech std is ~35 Hz)
130
+ pitch_std_score = float(np.clip(100.0 * np.exp(-((avg_pitch_std - 38.0) / 30.0)**2), 0.0, 100.0))
131
+
132
+ # 2) Pitch range score (ideal conversational range is ~80-160 Hz between 10th and 90th percentile)
133
+ pitch_range_score = float(np.clip(100.0 * (1.0 - np.exp(-avg_pitch_range / 50.0)), 0.0, 100.0))
134
+
135
+ # 3) Pause ratio score (ideal conversational pause ratio is ~15% to 30%)
136
+ pause_score = float(np.clip(100.0 * np.exp(-((avg_pause_ratio - 0.22) / 0.16)**2), 0.0, 100.0))
137
+
138
+ # Combine purely from DSP weights without artificial score clamping
139
+ raw_naturalness = 0.40 * pitch_std_score + 0.35 * pitch_range_score + 0.25 * pause_score
140
+ voice_naturalness_val = int(np.round(np.clip(raw_naturalness, 0.0, 100.0)))
141
+
142
+ # --- 3. PURE DSP SCORING: AUDIO QUALITY ---
143
+ # Depends strictly on: spectral centroid, spectral bandwidth, RMS consistency, ZCR consistency
144
+
145
+ # 1) Spectral Centroid score (ideal voice clarity is ~1800-3000 Hz)
146
+ sc_score = float(np.clip(100.0 * np.exp(-((avg_sc_mean - 2300.0) / 1400.0)**2), 0.0, 100.0))
147
+
148
+ # 2) Spectral Bandwidth score (square-root normalization for rich harmonic structure)
149
+ sb_score = float(np.clip(100.0 * np.sqrt(min(avg_sb_mean, 2600.0) / 2600.0), 0.0, 100.0))
150
+
151
+ # Combine purely from DSP quality components
152
+ raw_quality = 0.30 * sc_score + 0.30 * sb_score + 0.20 * avg_rms_cons + 0.20 * avg_zcr_cons
153
+ audio_quality_val = int(np.round(np.clip(raw_quality, 0.0, 100.0)))
154
+
155
+ # --- 4. DETECTED CHARACTERISTICS (DERIVED EXCLUSIVELY FROM DSP) ---
156
+ characteristics = []
157
+
158
+ # Pitch evaluations
159
+ if avg_pitch_std < 12.0:
160
+ characteristics.append("⚠ Voice sounds flat or monotone")
161
+ elif avg_pitch_std > 70.0:
162
+ characteristics.append("⚠ Unnatural jumps in voice pitch")
163
+ else:
164
+ characteristics.append("✓ Lively and natural speaking voice")
165
+
166
+ # Rhythm evaluations
167
+ if avg_pause_ratio < 0.08:
168
+ characteristics.append("⚠ Robotic speaking rhythm with no pauses")
169
+ elif avg_pause_ratio > 0.45:
170
+ characteristics.append("⚠ Unusually long awkward silences")
171
+ else:
172
+ characteristics.append("✓ Natural conversational breathing and pauses")
173
+
174
+ # Spectral frequency evaluations
175
+ if avg_sb_mean < 1100.0:
176
+ characteristics.append("⚠ Audio sounds muffled or compressed")
177
+ elif avg_sc_mean >= 1500.0:
178
+ characteristics.append("✓ Crisp and clear recording quality")
179
+ else:
180
+ characteristics.append("⚠ Sound is muffled or lacks detail")
181
+
182
+ # Recording consistency evaluations
183
+ if avg_rms_cons >= 70.0 and avg_zcr_cons >= 70.0:
184
+ characteristics.append("✓ Steady volume and clean recording")
185
+ elif avg_rms_cons < 50.0:
186
+ characteristics.append("⚠ Volume levels jump around during recording")
187
+
188
+ characteristics = characteristics[:4]
189
+
190
+ # --- 5. STRUCTURED RESULTS & CONFIDENCE ---
191
+ # Determine confidence and reasons based strictly on DSP signal characteristics
192
+ if voice_naturalness_val >= 75:
193
+ nat_conf = "High"
194
+ nat_reason = "Sounds like a real person talking naturally"
195
+ elif voice_naturalness_val >= 50:
196
+ nat_conf = "Medium"
197
+ nat_reason = "Normal speaking rhythm and voice tone"
198
+ else:
199
+ nat_conf = "High" if len(windows) >= 2 else "Medium"
200
+ nat_reason = "Voice sounds flat, robotic, or artificial"
201
+
202
+ if audio_quality_val >= 75:
203
+ qual_conf = "High"
204
+ qual_reason = "Clear and steady microphone sound"
205
+ elif audio_quality_val >= 50:
206
+ qual_conf = "Medium"
207
+ qual_reason = "Good audio clarity and balance"
208
+ else:
209
+ qual_conf = "Medium"
210
+ qual_reason = "Muffled sound or uneven volume levels"
211
+
212
+ structured_naturalness = {
213
+ "score": voice_naturalness_val,
214
+ "confidence": nat_conf,
215
+ "reason": nat_reason
216
+ }
217
+
218
+ structured_quality = {
219
+ "score": audio_quality_val,
220
+ "confidence": qual_conf,
221
+ "reason": qual_reason
222
+ }
223
+
224
+ # --- 6. ADVANCED ANALYSIS (COLLAPSED & UNDERSTANDABLE) ---
225
+ advanced = {
226
+ "Mean Pitch": f"{avg_mean_pitch:.1f} Hz",
227
+ "Pitch Variation": f"{avg_pitch_std:.1f} Hz",
228
+ "Spectral Profile": f"{int(avg_sc_mean)} Hz",
229
+ "Recording Consistency": f"{int(avg_rms_cons)}%"
230
+ }
231
+
232
+ return {
233
+ "voice_naturalness": structured_naturalness,
234
+ "audio_quality": structured_quality,
235
+ "characteristics": characteristics,
236
+ "advanced": advanced
237
+ }
app/state.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ class ModelInfo:
2
+ status: str = "uninitialized"
3
+ error: str | None = None
4
+ gary_feature_extractor = None
5
+ gary_model = None
6
+ bisher_feature_extractor = None
7
+ bisher_model = None
8
+
9
+ model_info = ModelInfo()
app/utils/audio.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import io
3
+ import base64
4
+ import librosa
5
+ import numpy as np
6
+ import soundfile as sf
7
+ import matplotlib
8
+ import matplotlib.pyplot as plt
9
+
10
+ # Use non-interactive backend for matplotlib to prevent thread issues
11
+ matplotlib.use('Agg')
12
+
13
+ # Register bundled ffmpeg binary so audioread can decode M4A/AAC files
14
+ try:
15
+ import imageio_ffmpeg
16
+ import audioread.ffdec
17
+ _ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
18
+ audioread.ffdec.COMMANDS = (_ffmpeg_exe, 'ffmpeg', 'avconv')
19
+ except ImportError:
20
+ pass # ffmpeg not bundled; M4A support may be unavailable
21
+
22
+ class AudioProcessor:
23
+ """
24
+ Utility class for AI audio preprocessing.
25
+ Handles mono conversion, resampling, normalization, silence trimming,
26
+ waveform extraction, and Mel Spectrogram generation.
27
+ """
28
+
29
+ def __init__(self, target_sr: int = 16000, max_waveform_points: int = 500, max_cache_files: int = 5):
30
+ self.target_sr = target_sr
31
+ self.max_waveform_points = max_waveform_points
32
+ self.max_cache_files = max_cache_files
33
+
34
+ def _cleanup_cache(self, cache_dir: str):
35
+ """Keep only the most recent 'max_cache_files' processed files in the cache."""
36
+ try:
37
+ files = [os.path.join(cache_dir, f) for f in os.listdir(cache_dir) if f.endswith('_processed.wav')]
38
+ if len(files) <= self.max_cache_files:
39
+ return
40
+
41
+ # Sort files by modification time, oldest first
42
+ files.sort(key=os.path.getmtime)
43
+
44
+ # Delete oldest files exceeding the limit
45
+ files_to_delete = files[:-self.max_cache_files]
46
+ for f in files_to_delete:
47
+ try:
48
+ os.remove(f)
49
+ except OSError:
50
+ pass
51
+ except Exception as e:
52
+ print(f"Error cleaning up cache: {e}")
53
+
54
+ def process(self, file_path: str, cache_dir: str, analysis_id: str) -> dict:
55
+ """
56
+ Executes the full preprocessing pipeline on the given audio file.
57
+
58
+ Args:
59
+ file_path: Absolute path to the uploaded temporary audio file.
60
+ cache_dir: Directory to save the processed output.
61
+ analysis_id: Unique UUID to use for filename generation.
62
+
63
+ Returns:
64
+ Dictionary containing waveform, spectrogram image, and metadata.
65
+ """
66
+ # Ensure cache directory exists
67
+ os.makedirs(cache_dir, exist_ok=True)
68
+
69
+ # 1. Load, convert to mono, and resample
70
+ # librosa automatically converts to mono if mono=True (which is the default)
71
+ y, sr = librosa.load(file_path, sr=self.target_sr, mono=True)
72
+
73
+ # 2. Normalize amplitude to range [-1.0, 1.0]
74
+ y_normalized = librosa.util.normalize(y)
75
+
76
+ # 4. Save processed audio to cache for future Wav2Vec2 inference
77
+ processed_audio_path = os.path.join(cache_dir, f"{analysis_id}_processed.wav")
78
+ sf.write(processed_audio_path, y_normalized, self.target_sr)
79
+
80
+ # 5. Extract Waveform (downsample to max 500 points)
81
+ if len(y_normalized) > self.max_waveform_points:
82
+ # We use an integer step size to slice the numpy array quickly
83
+ step = len(y_normalized) // self.max_waveform_points
84
+ # Alternative is taking max/avg per bin, but simple slice is fast and acceptable for overview
85
+ # Better approach for UI: calculate RMS or max amplitude per bin
86
+ y_split = np.array_split(y_normalized, self.max_waveform_points)
87
+ waveform = [float(np.max(np.abs(bin))) for bin in y_split]
88
+ else:
89
+ waveform = [float(val) for val in y_normalized]
90
+
91
+ # 6. Generate Mel Spectrogram
92
+ # Compute mel-scaled spectrogram
93
+ S = librosa.feature.melspectrogram(y=y_normalized, sr=self.target_sr, n_mels=128, fmax=8000)
94
+ # Convert power spectrogram to dB (log scale)
95
+ S_dB = librosa.power_to_db(S, ref=np.max)
96
+
97
+ # Render image with dark mode styling
98
+ fig, ax = plt.subplots(figsize=(10, 4))
99
+
100
+ # Display the spectrogram
101
+ img = librosa.display.specshow(S_dB, sr=self.target_sr, x_axis='time', y_axis='mel', fmax=8000, ax=ax, cmap='magma')
102
+
103
+ # Add labels and style axes for dark UI
104
+ ax.set_ylabel('Frequency (Hz)', color='#9ca3af', fontsize=10, labelpad=8)
105
+ ax.set_xlabel('Time (s)', color='#9ca3af', fontsize=10, labelpad=8)
106
+
107
+ # Style the ticks
108
+ ax.tick_params(colors='#9ca3af', labelsize=9)
109
+
110
+ # Remove top and right spines for a cleaner look
111
+ ax.spines['top'].set_visible(False)
112
+ ax.spines['right'].set_visible(False)
113
+ ax.spines['bottom'].set_color('#4b5563')
114
+ ax.spines['left'].set_color('#4b5563')
115
+
116
+ # Save to buffer
117
+ buf = io.BytesIO()
118
+ plt.savefig(buf, format='png', bbox_inches='tight', transparent=True, dpi=120)
119
+ plt.close(fig)
120
+
121
+ # Encode to Base64
122
+ buf.seek(0)
123
+ img_b64 = base64.b64encode(buf.read()).decode('utf-8')
124
+ spectrogram_b64 = f"data:image/png;base64,{img_b64}"
125
+
126
+ # Calculate final metadata
127
+ duration = librosa.get_duration(y=y_normalized, sr=self.target_sr)
128
+ peak_amp = float(np.max(np.abs(y_normalized)))
129
+
130
+ # Clean up old cache files
131
+ self._cleanup_cache(cache_dir)
132
+
133
+ return {
134
+ "sample_rate": self.target_sr,
135
+ "duration": duration,
136
+ "channels": 1,
137
+ "peak_amplitude": peak_amp,
138
+ "waveform": waveform,
139
+ "spectrogram_image": spectrogram_b64,
140
+ "processed_audio_path": processed_audio_path
141
+ }
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.0
3
+ python-dotenv==1.0.1
4
+ python-multipart==0.0.9
5
+ pydantic-settings==2.5.0
6
+ pydantic==2.9.0
7
+ mutagen==1.47.0
8
+ aiofiles==24.1.0
9
+ librosa==0.10.2
10
+ numpy==1.26.4
11
+ soundfile==0.12.1
12
+ matplotlib==3.9.0
13
+ torch>=2.1.0
14
+ torchvision>=0.16.0
15
+ torchaudio>=2.1.0
16
+ transformers>=4.35.0
17
+ psutil>=5.9.0
18
+ imageio-ffmpeg>=0.6.0
scripts/benchmark_models.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import glob
3
+ import json
4
+ import time
5
+ import librosa
6
+ import torch
7
+ from transformers import AutoModelForAudioClassification, AutoFeatureExtractor
8
+ from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
9
+
10
+ def get_audio_files():
11
+ # Load all class_0 (Real/Human) and class_1 (Fake/AI) from the data directory
12
+ # class_0 -> label 0
13
+ # class_1 -> label 1
14
+ files = []
15
+
16
+ # Path is relative to the backend folder when running this script, so ../data
17
+ data_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "data"))
18
+
19
+ class_0_files = glob.glob(os.path.join(data_dir, "class_0", "*.wav")) + glob.glob(os.path.join(data_dir, "class_0", "*.mp3"))
20
+ for f in class_0_files:
21
+ files.append((f, 0))
22
+
23
+ class_1_files = glob.glob(os.path.join(data_dir, "class_1", "*.wav")) + glob.glob(os.path.join(data_dir, "class_1", "*.mp3"))
24
+ for f in class_1_files:
25
+ files.append((f, 1))
26
+
27
+ return files
28
+
29
+ def get_fake_label_index(id2label):
30
+ # Dynamically find which index corresponds to "fake", "spoof", "ai"
31
+ # and which is "real", "human", "bonafide"
32
+ fake_idx = 1 # default
33
+ for idx, label in id2label.items():
34
+ l_lower = label.lower()
35
+ if "fake" in l_lower or "spoof" in l_lower or "ai" in l_lower:
36
+ fake_idx = idx
37
+ break
38
+ return fake_idx
39
+
40
+ def benchmark():
41
+ models_to_test = [
42
+ "garystafford/wav2vec2-deepfake-voice-detector",
43
+ "Bisher/wav2vec2_ASV_deepfake_audio_detection"
44
+ ]
45
+
46
+ audio_files = get_audio_files()
47
+ if not audio_files:
48
+ print("No audio files found in ../../data/class_0 or ../../data/class_1")
49
+ return
50
+
51
+ print(f"Found {len(audio_files)} total audio files for benchmarking.")
52
+
53
+ results = {}
54
+
55
+ for model_name in models_to_test:
56
+ print(f"\n======================================")
57
+ print(f"Loading Model: {model_name}")
58
+
59
+ try:
60
+ feature_extractor = AutoFeatureExtractor.from_pretrained(model_name)
61
+ model = AutoModelForAudioClassification.from_pretrained(model_name)
62
+
63
+ fake_idx = get_fake_label_index(model.config.id2label)
64
+ print(f"Dynamic Label Mapping -> Fake/AI is index {fake_idx}")
65
+
66
+ y_true = []
67
+ y_pred = []
68
+
69
+ start_time = time.time()
70
+
71
+ for file_path, true_label in audio_files:
72
+ # Target sample rate is typically 16000 for wav2vec2 models
73
+ target_sr = feature_extractor.sampling_rate if hasattr(feature_extractor, "sampling_rate") else 16000
74
+
75
+ y, sr = librosa.load(file_path, sr=target_sr, mono=True)
76
+
77
+ inputs = feature_extractor(y, sampling_rate=target_sr, return_tensors="pt", padding=True)
78
+
79
+ with torch.no_grad():
80
+ logits = model(**inputs).logits
81
+ probabilities = torch.nn.functional.softmax(logits, dim=-1)
82
+ probs = probabilities[0].tolist()
83
+
84
+ predicted_idx = 0 if probs[0] > probs[1] else 1
85
+
86
+ # If predicted_idx matches fake_idx, the model predicted Fake (1). Else Real (0).
87
+ predicted_label = 1 if predicted_idx == fake_idx else 0
88
+
89
+ y_true.append(true_label)
90
+ y_pred.append(predicted_label)
91
+
92
+ elapsed = time.time() - start_time
93
+
94
+ # Calculate metrics
95
+ accuracy = accuracy_score(y_true, y_pred)
96
+ # Use zero_division=0 to handle cases where it predicts all one class
97
+ precision = precision_score(y_true, y_pred, zero_division=0)
98
+ recall = recall_score(y_true, y_pred, zero_division=0)
99
+ f1 = f1_score(y_true, y_pred, zero_division=0)
100
+
101
+ print(f"Metrics for {model_name}:")
102
+ print(f" Accuracy: {accuracy:.4f}")
103
+ print(f" Precision: {precision:.4f}")
104
+ print(f" Recall: {recall:.4f}")
105
+ print(f" F1 Score: {f1:.4f}")
106
+ print(f" Time: {elapsed:.2f}s")
107
+
108
+ results[model_name] = {
109
+ "accuracy": round(accuracy, 4),
110
+ "precision": round(precision, 4),
111
+ "recall": round(recall, 4),
112
+ "f1_score": round(f1, 4),
113
+ "total_time_seconds": round(elapsed, 2)
114
+ }
115
+
116
+ except Exception as e:
117
+ print(f"Error benchmarking {model_name}: {e}")
118
+ results[model_name] = {
119
+ "error": str(e)
120
+ }
121
+
122
+ # Save results
123
+ output_file = os.path.join(os.path.dirname(__file__), "..", "..", "benchmark_results.json")
124
+ with open(output_file, "w") as f:
125
+ json.dump(results, f, indent=4)
126
+
127
+ print(f"\nBenchmark complete. Results saved to {output_file}")
128
+
129
+ if __name__ == "__main__":
130
+ benchmark()