Spaces:
Running
Running
GitHub Actions commited on
Commit ·
96fd859
0
Parent(s):
Automated backend deployment from GitHub Actions
Browse files- .env.example +14 -0
- Dockerfile +31 -0
- README.md +12 -0
- app/__init__.py +1 -0
- app/config.py +32 -0
- app/exceptions.py +45 -0
- app/main.py +144 -0
- app/models.py +102 -0
- app/routers/__init__.py +1 -0
- app/routers/analysis.py +245 -0
- app/services/detector.py +189 -0
- app/services/forensics.py +237 -0
- app/state.py +9 -0
- app/utils/audio.py +141 -0
- requirements.txt +18 -0
- scripts/benchmark_models.py +130 -0
.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()
|