Upload 70 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- Dockerfile +70 -0
- app/__init__.py +4 -0
- app/__pycache__/__init__.cpython-312.pyc +0 -0
- app/__pycache__/config.cpython-312.pyc +0 -0
- app/__pycache__/main.cpython-312.pyc +0 -0
- app/api/__init__.py +9 -0
- app/api/__pycache__/__init__.cpython-312.pyc +0 -0
- app/api/__pycache__/dependencies.cpython-312.pyc +0 -0
- app/api/dependencies.py +143 -0
- app/api/middleware/__init__.py +13 -0
- app/api/middleware/__pycache__/__init__.cpython-312.pyc +0 -0
- app/api/middleware/__pycache__/auth.cpython-312.pyc +0 -0
- app/api/middleware/__pycache__/error_handler.cpython-312.pyc +0 -0
- app/api/middleware/__pycache__/rate_limiter.cpython-312.pyc +0 -0
- app/api/middleware/auth.py +107 -0
- app/api/middleware/error_handler.py +130 -0
- app/api/middleware/rate_limiter.py +90 -0
- app/api/routes/__init__.py +9 -0
- app/api/routes/__pycache__/__init__.cpython-312.pyc +0 -0
- app/api/routes/__pycache__/federated.cpython-312.pyc +0 -0
- app/api/routes/__pycache__/health.cpython-312.pyc +0 -0
- app/api/routes/__pycache__/voice_detection.cpython-312.pyc +0 -0
- app/api/routes/federated.py +100 -0
- app/api/routes/health.py +101 -0
- app/api/routes/voice_detection.py +150 -0
- app/config.py +124 -0
- app/main.py +182 -0
- app/ml/__init__.py +11 -0
- app/ml/__pycache__/__init__.cpython-312.pyc +0 -0
- app/ml/__pycache__/inference.cpython-312.pyc +0 -0
- app/ml/__pycache__/model_loader.cpython-312.pyc +0 -0
- app/ml/__pycache__/preprocessing.cpython-312.pyc +0 -0
- app/ml/inference.py +235 -0
- app/ml/model_loader.py +246 -0
- app/ml/preprocessing.py +155 -0
- app/models/__init__.py +19 -0
- app/models/__pycache__/__init__.cpython-312.pyc +0 -0
- app/models/__pycache__/enums.cpython-312.pyc +0 -0
- app/models/__pycache__/request.cpython-312.pyc +0 -0
- app/models/__pycache__/response.cpython-312.pyc +0 -0
- app/models/enums.py +57 -0
- app/models/request.py +99 -0
- app/models/response.py +264 -0
- app/services/__init__.py +19 -0
- app/services/__pycache__/__init__.cpython-312.pyc +0 -0
- app/services/__pycache__/audio_forensics.cpython-312.pyc +0 -0
- app/services/__pycache__/audio_processor.cpython-312.pyc +0 -0
- app/services/__pycache__/explainability.cpython-312.pyc +0 -0
- app/services/__pycache__/federated_learning.cpython-312.pyc +0 -0
- app/services/__pycache__/score_calculators.cpython-312.pyc +0 -0
Dockerfile
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# VoiceAuth API Dockerfile
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# Multi-stage build for optimized production image
|
| 5 |
+
|
| 6 |
+
# -----------------------------------------------------------------------------
|
| 7 |
+
# Stage 1: Builder
|
| 8 |
+
# -----------------------------------------------------------------------------
|
| 9 |
+
FROM python:3.11-slim AS builder
|
| 10 |
+
|
| 11 |
+
# Install build dependencies
|
| 12 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 13 |
+
build-essential \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
# Create virtual environment
|
| 17 |
+
RUN python -m venv /opt/venv
|
| 18 |
+
ENV PATH="/opt/venv/bin:$PATH"
|
| 19 |
+
|
| 20 |
+
# Install Python dependencies
|
| 21 |
+
COPY requirements.txt .
|
| 22 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 23 |
+
pip install --no-cache-dir -r requirements.txt
|
| 24 |
+
|
| 25 |
+
# -----------------------------------------------------------------------------
|
| 26 |
+
# Stage 2: Production
|
| 27 |
+
# -----------------------------------------------------------------------------
|
| 28 |
+
FROM python:3.11-slim AS production
|
| 29 |
+
|
| 30 |
+
# Install runtime dependencies
|
| 31 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 32 |
+
ffmpeg \
|
| 33 |
+
libsndfile1 \
|
| 34 |
+
curl \
|
| 35 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 36 |
+
|
| 37 |
+
# Create non-root user
|
| 38 |
+
RUN useradd --create-home --uid 1000 appuser
|
| 39 |
+
|
| 40 |
+
# Copy virtual environment from builder
|
| 41 |
+
COPY --from=builder /opt/venv /opt/venv
|
| 42 |
+
ENV PATH="/opt/venv/bin:$PATH"
|
| 43 |
+
|
| 44 |
+
# Set working directory
|
| 45 |
+
WORKDIR /app
|
| 46 |
+
|
| 47 |
+
# Copy application code
|
| 48 |
+
COPY --chown=appuser:appuser . .
|
| 49 |
+
|
| 50 |
+
# Create directories for models and logs
|
| 51 |
+
RUN mkdir -p /app/models /app/logs && \
|
| 52 |
+
chown -R appuser:appuser /app
|
| 53 |
+
|
| 54 |
+
# Switch to non-root user
|
| 55 |
+
USER appuser
|
| 56 |
+
|
| 57 |
+
# Environment variables
|
| 58 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 59 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 60 |
+
PORT=7860
|
| 61 |
+
|
| 62 |
+
# Expose port
|
| 63 |
+
EXPOSE 7860
|
| 64 |
+
|
| 65 |
+
# Health check
|
| 66 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
| 67 |
+
CMD curl -f http://localhost:7860/api/health || exit 1
|
| 68 |
+
|
| 69 |
+
# Run application
|
| 70 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
app/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VoiceAuth - AI Voice Detection API."""
|
| 2 |
+
|
| 3 |
+
__version__ = "1.0.0"
|
| 4 |
+
__author__ = "VoiceAuth Team"
|
app/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (277 Bytes). View file
|
|
|
app/__pycache__/config.cpython-312.pyc
ADDED
|
Binary file (5 kB). View file
|
|
|
app/__pycache__/main.cpython-312.pyc
ADDED
|
Binary file (5.14 kB). View file
|
|
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API package."""
|
| 2 |
+
|
| 3 |
+
from app.api.routes import health
|
| 4 |
+
from app.api.routes import voice_detection
|
| 5 |
+
|
| 6 |
+
__all__ = [
|
| 7 |
+
"health",
|
| 8 |
+
"voice_detection",
|
| 9 |
+
]
|
app/api/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (324 Bytes). View file
|
|
|
app/api/__pycache__/dependencies.cpython-312.pyc
ADDED
|
Binary file (4.31 kB). View file
|
|
|
app/api/dependencies.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI dependency injection.
|
| 3 |
+
|
| 4 |
+
Provides dependencies for route handlers.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Annotated
|
| 8 |
+
|
| 9 |
+
from fastapi import Depends
|
| 10 |
+
from fastapi import Header
|
| 11 |
+
from fastapi import HTTPException
|
| 12 |
+
from fastapi import Request
|
| 13 |
+
from fastapi import status
|
| 14 |
+
|
| 15 |
+
from app.config import Settings
|
| 16 |
+
from app.config import get_settings
|
| 17 |
+
from app.ml.model_loader import ModelLoader
|
| 18 |
+
from app.services.voice_detector import VoiceDetector
|
| 19 |
+
from app.utils.logger import get_logger
|
| 20 |
+
|
| 21 |
+
logger = get_logger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_model_loader(request: Request) -> ModelLoader:
|
| 25 |
+
"""
|
| 26 |
+
Get ModelLoader from app state.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
request: FastAPI request object
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
ModelLoader instance from app state
|
| 33 |
+
"""
|
| 34 |
+
if hasattr(request.app.state, "model_loader"):
|
| 35 |
+
return request.app.state.model_loader
|
| 36 |
+
return ModelLoader()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def get_voice_detector(
|
| 40 |
+
model_loader: Annotated[ModelLoader, Depends(get_model_loader)],
|
| 41 |
+
) -> VoiceDetector:
|
| 42 |
+
"""
|
| 43 |
+
Get VoiceDetector instance.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
model_loader: ModelLoader from dependency
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
Configured VoiceDetector instance
|
| 50 |
+
"""
|
| 51 |
+
return VoiceDetector(model_loader=model_loader)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def validate_api_key(
|
| 55 |
+
x_api_key: Annotated[
|
| 56 |
+
str | None,
|
| 57 |
+
Header(
|
| 58 |
+
alias="x-api-key",
|
| 59 |
+
description="API key for authentication",
|
| 60 |
+
),
|
| 61 |
+
] = None,
|
| 62 |
+
settings: Annotated[Settings, Depends(get_settings)] = None, # type: ignore
|
| 63 |
+
) -> str:
|
| 64 |
+
"""
|
| 65 |
+
Validate API key from request header.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
x_api_key: API key from x-api-key header
|
| 69 |
+
settings: Application settings
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
Validated API key
|
| 73 |
+
|
| 74 |
+
Raises:
|
| 75 |
+
HTTPException: 401 if API key is missing or invalid
|
| 76 |
+
"""
|
| 77 |
+
if settings is None:
|
| 78 |
+
settings = get_settings()
|
| 79 |
+
|
| 80 |
+
if not x_api_key:
|
| 81 |
+
logger.warning("Request without API key")
|
| 82 |
+
raise HTTPException(
|
| 83 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 84 |
+
detail="API key is required. Provide it in the x-api-key header.",
|
| 85 |
+
headers={"WWW-Authenticate": "ApiKey"},
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Get valid API keys
|
| 89 |
+
valid_keys = settings.api_keys_list
|
| 90 |
+
|
| 91 |
+
if not valid_keys:
|
| 92 |
+
logger.error("No API keys configured on server")
|
| 93 |
+
raise HTTPException(
|
| 94 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 95 |
+
detail="Server configuration error",
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Constant-time comparison to prevent timing attacks
|
| 99 |
+
key_valid = False
|
| 100 |
+
for valid_key in valid_keys:
|
| 101 |
+
if _constant_time_compare(x_api_key, valid_key):
|
| 102 |
+
key_valid = True
|
| 103 |
+
break
|
| 104 |
+
|
| 105 |
+
if not key_valid:
|
| 106 |
+
logger.warning(
|
| 107 |
+
"Invalid API key attempt",
|
| 108 |
+
key_prefix=x_api_key[:8] + "..." if len(x_api_key) > 8 else "***",
|
| 109 |
+
)
|
| 110 |
+
raise HTTPException(
|
| 111 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 112 |
+
detail="Invalid API key",
|
| 113 |
+
headers={"WWW-Authenticate": "ApiKey"},
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
return x_api_key
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _constant_time_compare(val1: str, val2: str) -> bool:
|
| 120 |
+
"""
|
| 121 |
+
Constant-time string comparison to prevent timing attacks.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
val1: First string
|
| 125 |
+
val2: Second string
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
True if strings are equal
|
| 129 |
+
"""
|
| 130 |
+
if len(val1) != len(val2):
|
| 131 |
+
return False
|
| 132 |
+
|
| 133 |
+
result = 0
|
| 134 |
+
for x, y in zip(val1, val2):
|
| 135 |
+
result |= ord(x) ^ ord(y)
|
| 136 |
+
|
| 137 |
+
return result == 0
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# Type aliases for cleaner route signatures
|
| 141 |
+
ValidatedApiKey = Annotated[str, Depends(validate_api_key)]
|
| 142 |
+
VoiceDetectorDep = Annotated[VoiceDetector, Depends(get_voice_detector)]
|
| 143 |
+
SettingsDep = Annotated[Settings, Depends(get_settings)]
|
app/api/middleware/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Middleware package."""
|
| 2 |
+
|
| 3 |
+
from app.api.middleware.auth import APIKeyMiddleware
|
| 4 |
+
from app.api.middleware.error_handler import setup_exception_handlers
|
| 5 |
+
from app.api.middleware.rate_limiter import get_limiter
|
| 6 |
+
from app.api.middleware.rate_limiter import limiter
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"APIKeyMiddleware",
|
| 10 |
+
"setup_exception_handlers",
|
| 11 |
+
"limiter",
|
| 12 |
+
"get_limiter",
|
| 13 |
+
]
|
app/api/middleware/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (507 Bytes). View file
|
|
|
app/api/middleware/__pycache__/auth.cpython-312.pyc
ADDED
|
Binary file (3.44 kB). View file
|
|
|
app/api/middleware/__pycache__/error_handler.cpython-312.pyc
ADDED
|
Binary file (4.05 kB). View file
|
|
|
app/api/middleware/__pycache__/rate_limiter.cpython-312.pyc
ADDED
|
Binary file (2.46 kB). View file
|
|
|
app/api/middleware/auth.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API Key authentication middleware.
|
| 3 |
+
|
| 4 |
+
Provides middleware for API key validation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 8 |
+
from starlette.requests import Request
|
| 9 |
+
from starlette.responses import JSONResponse
|
| 10 |
+
|
| 11 |
+
from app.config import get_settings
|
| 12 |
+
from app.utils.logger import get_logger
|
| 13 |
+
|
| 14 |
+
logger = get_logger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class APIKeyMiddleware(BaseHTTPMiddleware):
|
| 18 |
+
"""
|
| 19 |
+
Middleware for API key authentication.
|
| 20 |
+
|
| 21 |
+
This middleware checks for valid API keys on protected endpoints.
|
| 22 |
+
Public endpoints (health, docs) are excluded from authentication.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
# Endpoints that don't require authentication
|
| 26 |
+
PUBLIC_PATHS: set[str] = {
|
| 27 |
+
"/api/health",
|
| 28 |
+
"/api/ready",
|
| 29 |
+
"/api/languages",
|
| 30 |
+
"/api/",
|
| 31 |
+
"/docs",
|
| 32 |
+
"/redoc",
|
| 33 |
+
"/openapi.json",
|
| 34 |
+
"/",
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
async def dispatch(self, request: Request, call_next):
|
| 38 |
+
"""
|
| 39 |
+
Process request and validate API key for protected endpoints.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
request: Incoming request
|
| 43 |
+
call_next: Next middleware/handler
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
Response from next handler or 401 error
|
| 47 |
+
"""
|
| 48 |
+
# Skip authentication for public paths
|
| 49 |
+
path = request.url.path.rstrip("/") or "/"
|
| 50 |
+
|
| 51 |
+
# Check if path is public
|
| 52 |
+
is_public = path in self.PUBLIC_PATHS or any(
|
| 53 |
+
path.startswith(public.rstrip("/")) for public in self.PUBLIC_PATHS if public != "/"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
if is_public:
|
| 57 |
+
return await call_next(request)
|
| 58 |
+
|
| 59 |
+
# Get API key from header
|
| 60 |
+
api_key = request.headers.get("x-api-key")
|
| 61 |
+
|
| 62 |
+
if not api_key:
|
| 63 |
+
logger.warning(
|
| 64 |
+
"Request without API key",
|
| 65 |
+
path=path,
|
| 66 |
+
method=request.method,
|
| 67 |
+
)
|
| 68 |
+
return JSONResponse(
|
| 69 |
+
status_code=401,
|
| 70 |
+
content={
|
| 71 |
+
"status": "error",
|
| 72 |
+
"message": "API key is required",
|
| 73 |
+
},
|
| 74 |
+
headers={"WWW-Authenticate": "ApiKey"},
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Validate API key
|
| 78 |
+
settings = get_settings()
|
| 79 |
+
valid_keys = settings.api_keys_list
|
| 80 |
+
|
| 81 |
+
if not valid_keys:
|
| 82 |
+
logger.error("No API keys configured")
|
| 83 |
+
return JSONResponse(
|
| 84 |
+
status_code=500,
|
| 85 |
+
content={
|
| 86 |
+
"status": "error",
|
| 87 |
+
"message": "Server configuration error",
|
| 88 |
+
},
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
if api_key not in valid_keys:
|
| 92 |
+
logger.warning(
|
| 93 |
+
"Invalid API key",
|
| 94 |
+
path=path,
|
| 95 |
+
key_prefix=api_key[:8] + "..." if len(api_key) > 8 else "***",
|
| 96 |
+
)
|
| 97 |
+
return JSONResponse(
|
| 98 |
+
status_code=401,
|
| 99 |
+
content={
|
| 100 |
+
"status": "error",
|
| 101 |
+
"message": "Invalid API key",
|
| 102 |
+
},
|
| 103 |
+
headers={"WWW-Authenticate": "ApiKey"},
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Continue to next handler
|
| 107 |
+
return await call_next(request)
|
app/api/middleware/error_handler.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Global error handling for FastAPI application.
|
| 3 |
+
|
| 4 |
+
Provides exception handlers for consistent error responses.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI
|
| 8 |
+
from fastapi import HTTPException
|
| 9 |
+
from fastapi import Request
|
| 10 |
+
from fastapi.exceptions import RequestValidationError
|
| 11 |
+
from starlette.responses import JSONResponse
|
| 12 |
+
|
| 13 |
+
from app.utils.logger import get_logger
|
| 14 |
+
|
| 15 |
+
logger = get_logger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def validation_exception_handler(
|
| 19 |
+
request: Request,
|
| 20 |
+
exc: RequestValidationError,
|
| 21 |
+
) -> JSONResponse:
|
| 22 |
+
"""
|
| 23 |
+
Handle Pydantic validation errors.
|
| 24 |
+
|
| 25 |
+
Formats validation errors into a consistent response format.
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
request: Incoming request
|
| 29 |
+
exc: Validation exception
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
JSON response with error details
|
| 33 |
+
"""
|
| 34 |
+
errors = exc.errors()
|
| 35 |
+
|
| 36 |
+
# Format error messages
|
| 37 |
+
error_messages = []
|
| 38 |
+
for error in errors:
|
| 39 |
+
loc = " -> ".join(str(x) for x in error.get("loc", []))
|
| 40 |
+
msg = error.get("msg", "Validation error")
|
| 41 |
+
error_messages.append(f"{loc}: {msg}")
|
| 42 |
+
|
| 43 |
+
logger.warning(
|
| 44 |
+
"Validation error",
|
| 45 |
+
path=request.url.path,
|
| 46 |
+
errors=error_messages,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
return JSONResponse(
|
| 50 |
+
status_code=422,
|
| 51 |
+
content={
|
| 52 |
+
"status": "error",
|
| 53 |
+
"message": "Validation error",
|
| 54 |
+
"details": {
|
| 55 |
+
"errors": error_messages,
|
| 56 |
+
},
|
| 57 |
+
},
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
async def http_exception_handler(
|
| 62 |
+
request: Request,
|
| 63 |
+
exc: HTTPException,
|
| 64 |
+
) -> JSONResponse:
|
| 65 |
+
"""
|
| 66 |
+
Handle HTTP exceptions.
|
| 67 |
+
|
| 68 |
+
Formats HTTP exceptions into a consistent response format.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
request: Incoming request
|
| 72 |
+
exc: HTTP exception
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
JSON response with error details
|
| 76 |
+
"""
|
| 77 |
+
return JSONResponse(
|
| 78 |
+
status_code=exc.status_code,
|
| 79 |
+
content={
|
| 80 |
+
"status": "error",
|
| 81 |
+
"message": exc.detail,
|
| 82 |
+
},
|
| 83 |
+
headers=exc.headers,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
async def general_exception_handler(
|
| 88 |
+
request: Request,
|
| 89 |
+
exc: Exception,
|
| 90 |
+
) -> JSONResponse:
|
| 91 |
+
"""
|
| 92 |
+
Handle unexpected exceptions.
|
| 93 |
+
|
| 94 |
+
Logs the exception and returns a generic error response.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
request: Incoming request
|
| 98 |
+
exc: Unexpected exception
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
JSON response with generic error message
|
| 102 |
+
"""
|
| 103 |
+
logger.exception(
|
| 104 |
+
"Unhandled exception",
|
| 105 |
+
path=request.url.path,
|
| 106 |
+
method=request.method,
|
| 107 |
+
error=str(exc),
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
return JSONResponse(
|
| 111 |
+
status_code=500,
|
| 112 |
+
content={
|
| 113 |
+
"status": "error",
|
| 114 |
+
"message": "Internal server error",
|
| 115 |
+
},
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def setup_exception_handlers(app: FastAPI) -> None:
|
| 120 |
+
"""
|
| 121 |
+
Register all exception handlers with the FastAPI app.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
app: FastAPI application instance
|
| 125 |
+
"""
|
| 126 |
+
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
| 127 |
+
app.add_exception_handler(HTTPException, http_exception_handler)
|
| 128 |
+
app.add_exception_handler(Exception, general_exception_handler)
|
| 129 |
+
|
| 130 |
+
logger.debug("Exception handlers registered")
|
app/api/middleware/rate_limiter.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rate limiting middleware using SlowAPI.
|
| 3 |
+
|
| 4 |
+
Provides request rate limiting per API key.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from slowapi import Limiter
|
| 8 |
+
from slowapi.util import get_remote_address
|
| 9 |
+
from starlette.requests import Request
|
| 10 |
+
|
| 11 |
+
from app.config import get_settings
|
| 12 |
+
from app.utils.logger import get_logger
|
| 13 |
+
|
| 14 |
+
logger = get_logger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_api_key_or_ip(request: Request) -> str:
|
| 18 |
+
"""
|
| 19 |
+
Extract rate limit key from request.
|
| 20 |
+
|
| 21 |
+
Uses API key if present, otherwise falls back to IP address.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
request: Incoming request
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
Rate limit key (API key or IP)
|
| 28 |
+
"""
|
| 29 |
+
api_key = request.headers.get("x-api-key")
|
| 30 |
+
|
| 31 |
+
if api_key:
|
| 32 |
+
# Use API key for per-key rate limiting
|
| 33 |
+
return f"key:{api_key}"
|
| 34 |
+
|
| 35 |
+
# Fall back to IP address
|
| 36 |
+
return f"ip:{get_remote_address(request)}"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def get_limiter() -> Limiter:
|
| 40 |
+
"""
|
| 41 |
+
Create and configure rate limiter.
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
Configured Limiter instance
|
| 45 |
+
"""
|
| 46 |
+
settings = get_settings()
|
| 47 |
+
|
| 48 |
+
# Build default limit string
|
| 49 |
+
default_limit = f"{settings.RATE_LIMIT_REQUESTS}/minute"
|
| 50 |
+
|
| 51 |
+
return Limiter(
|
| 52 |
+
key_func=get_api_key_or_ip,
|
| 53 |
+
default_limits=[default_limit],
|
| 54 |
+
# Note: Redis storage will be configured in main.py if available
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# Global limiter instance
|
| 59 |
+
limiter = get_limiter()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def rate_limit_exceeded_handler(request: Request, exc: Exception):
|
| 63 |
+
"""
|
| 64 |
+
Handle rate limit exceeded errors.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
request: Request that exceeded the limit
|
| 68 |
+
exc: Rate limit exception
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
JSON response with 429 status
|
| 72 |
+
"""
|
| 73 |
+
from starlette.responses import JSONResponse
|
| 74 |
+
|
| 75 |
+
logger.warning(
|
| 76 |
+
"Rate limit exceeded",
|
| 77 |
+
path=request.url.path,
|
| 78 |
+
client=get_api_key_or_ip(request),
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
return JSONResponse(
|
| 82 |
+
status_code=429,
|
| 83 |
+
content={
|
| 84 |
+
"status": "error",
|
| 85 |
+
"message": "Rate limit exceeded. Please try again later.",
|
| 86 |
+
},
|
| 87 |
+
headers={
|
| 88 |
+
"Retry-After": "60",
|
| 89 |
+
},
|
| 90 |
+
)
|
app/api/routes/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Routes package."""
|
| 2 |
+
|
| 3 |
+
from app.api.routes import health
|
| 4 |
+
from app.api.routes import voice_detection
|
| 5 |
+
|
| 6 |
+
__all__ = [
|
| 7 |
+
"health",
|
| 8 |
+
"voice_detection",
|
| 9 |
+
]
|
app/api/routes/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (334 Bytes). View file
|
|
|
app/api/routes/__pycache__/federated.cpython-312.pyc
ADDED
|
Binary file (4 kB). View file
|
|
|
app/api/routes/__pycache__/health.cpython-312.pyc
ADDED
|
Binary file (3.18 kB). View file
|
|
|
app/api/routes/__pycache__/voice_detection.cpython-312.pyc
ADDED
|
Binary file (5.12 kB). View file
|
|
|
app/api/routes/federated.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Federated Learning API endpoints.
|
| 3 |
+
|
| 4 |
+
Provides endpoints for FL operations:
|
| 5 |
+
- Client registration
|
| 6 |
+
- Contribution submission
|
| 7 |
+
- Federation status
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter
|
| 11 |
+
from fastapi import HTTPException
|
| 12 |
+
from fastapi import status
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
from pydantic import Field
|
| 15 |
+
|
| 16 |
+
from app.api.dependencies import ValidatedApiKey
|
| 17 |
+
from app.services.federated_learning import fl_manager
|
| 18 |
+
|
| 19 |
+
router = APIRouter()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ClientRegistrationRequest(BaseModel):
|
| 23 |
+
"""Request to register as FL client."""
|
| 24 |
+
|
| 25 |
+
client_id: str = Field(..., min_length=3, max_length=64)
|
| 26 |
+
organization: str | None = Field(None, max_length=128)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ContributionRequest(BaseModel):
|
| 30 |
+
"""Request to submit a training contribution."""
|
| 31 |
+
|
| 32 |
+
client_id: str = Field(..., min_length=3, max_length=64)
|
| 33 |
+
gradient_hash: str = Field(..., min_length=16, max_length=128)
|
| 34 |
+
samples_trained: int = Field(..., ge=1, le=100000)
|
| 35 |
+
local_accuracy: float = Field(..., ge=0.0, le=1.0)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.post(
|
| 39 |
+
"/federated/register",
|
| 40 |
+
summary="Register as Federated Client",
|
| 41 |
+
description="Register as a federated learning participant.",
|
| 42 |
+
)
|
| 43 |
+
async def register_client(
|
| 44 |
+
request: ClientRegistrationRequest,
|
| 45 |
+
api_key: ValidatedApiKey,
|
| 46 |
+
) -> dict:
|
| 47 |
+
"""Register a new federated learning client."""
|
| 48 |
+
client = fl_manager.register_client(
|
| 49 |
+
client_id=request.client_id,
|
| 50 |
+
organization=request.organization,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
"status": "registered",
|
| 55 |
+
"client_id": client.client_id,
|
| 56 |
+
"organization": client.organization,
|
| 57 |
+
"registered_at": client.registered_at,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.post(
|
| 62 |
+
"/federated/contribute",
|
| 63 |
+
summary="Submit Training Contribution",
|
| 64 |
+
description="Submit model gradients from local training.",
|
| 65 |
+
)
|
| 66 |
+
async def submit_contribution(
|
| 67 |
+
request: ContributionRequest,
|
| 68 |
+
api_key: ValidatedApiKey,
|
| 69 |
+
) -> dict:
|
| 70 |
+
"""Submit a training contribution."""
|
| 71 |
+
result = fl_manager.submit_contribution(
|
| 72 |
+
client_id=request.client_id,
|
| 73 |
+
gradient_hash=request.gradient_hash,
|
| 74 |
+
samples_trained=request.samples_trained,
|
| 75 |
+
local_accuracy=request.local_accuracy,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
return result
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@router.get(
|
| 82 |
+
"/federated/status",
|
| 83 |
+
summary="Federation Status",
|
| 84 |
+
description="Get current federated learning status.",
|
| 85 |
+
)
|
| 86 |
+
async def federation_status() -> dict:
|
| 87 |
+
"""Get federation status."""
|
| 88 |
+
return fl_manager.get_federation_status()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.post(
|
| 92 |
+
"/federated/aggregate",
|
| 93 |
+
summary="Trigger Aggregation",
|
| 94 |
+
description="Trigger federated model aggregation (admin only).",
|
| 95 |
+
)
|
| 96 |
+
async def trigger_aggregation(
|
| 97 |
+
api_key: ValidatedApiKey,
|
| 98 |
+
) -> dict:
|
| 99 |
+
"""Trigger model aggregation."""
|
| 100 |
+
return fl_manager.simulate_aggregation()
|
app/api/routes/health.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Health check endpoints.
|
| 3 |
+
|
| 4 |
+
Provides health, readiness, and information endpoints.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter
|
| 8 |
+
|
| 9 |
+
from app.api.dependencies import VoiceDetectorDep
|
| 10 |
+
from app.models.enums import SupportedLanguage
|
| 11 |
+
from app.models.response import HealthResponse
|
| 12 |
+
from app.models.response import LanguagesResponse
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.get(
|
| 18 |
+
"/health",
|
| 19 |
+
response_model=HealthResponse,
|
| 20 |
+
summary="Health Check",
|
| 21 |
+
description="Check the health status of the API and ML model.",
|
| 22 |
+
)
|
| 23 |
+
async def health_check(
|
| 24 |
+
voice_detector: VoiceDetectorDep,
|
| 25 |
+
) -> HealthResponse:
|
| 26 |
+
"""
|
| 27 |
+
Get health status of the API.
|
| 28 |
+
|
| 29 |
+
Returns model loading status, device info, and supported languages.
|
| 30 |
+
"""
|
| 31 |
+
health = voice_detector.health_check()
|
| 32 |
+
|
| 33 |
+
return HealthResponse(
|
| 34 |
+
status=health["status"],
|
| 35 |
+
version=health["version"],
|
| 36 |
+
model_loaded=health["model_loaded"],
|
| 37 |
+
model_name=health.get("model_name"),
|
| 38 |
+
device=health.get("device"),
|
| 39 |
+
supported_languages=health["supported_languages"],
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@router.get(
|
| 44 |
+
"/ready",
|
| 45 |
+
summary="Readiness Check",
|
| 46 |
+
description="Check if the API is ready to accept requests.",
|
| 47 |
+
)
|
| 48 |
+
async def readiness_check(
|
| 49 |
+
voice_detector: VoiceDetectorDep,
|
| 50 |
+
) -> dict:
|
| 51 |
+
"""
|
| 52 |
+
Check if API is ready to accept requests.
|
| 53 |
+
|
| 54 |
+
Returns ready status based on model availability.
|
| 55 |
+
"""
|
| 56 |
+
health = voice_detector.health_check()
|
| 57 |
+
|
| 58 |
+
if health["model_loaded"]:
|
| 59 |
+
return {"status": "ready", "message": "API is ready to accept requests"}
|
| 60 |
+
else:
|
| 61 |
+
return {"status": "not_ready", "message": "Model is still loading"}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.get(
|
| 65 |
+
"/languages",
|
| 66 |
+
response_model=LanguagesResponse,
|
| 67 |
+
summary="Supported Languages",
|
| 68 |
+
description="Get the list of supported languages for voice detection.",
|
| 69 |
+
)
|
| 70 |
+
async def supported_languages() -> LanguagesResponse:
|
| 71 |
+
"""
|
| 72 |
+
Get list of supported languages.
|
| 73 |
+
|
| 74 |
+
Returns all languages supported by the voice detection API.
|
| 75 |
+
"""
|
| 76 |
+
languages = SupportedLanguage.values()
|
| 77 |
+
|
| 78 |
+
return LanguagesResponse(
|
| 79 |
+
languages=languages,
|
| 80 |
+
count=len(languages),
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@router.get(
|
| 85 |
+
"/",
|
| 86 |
+
summary="API Info",
|
| 87 |
+
description="Get basic API information.",
|
| 88 |
+
)
|
| 89 |
+
async def api_info() -> dict:
|
| 90 |
+
"""
|
| 91 |
+
Get basic API information.
|
| 92 |
+
|
| 93 |
+
Returns API name, version, and documentation links.
|
| 94 |
+
"""
|
| 95 |
+
return {
|
| 96 |
+
"name": "VoiceAuth API",
|
| 97 |
+
"description": "AI-Generated Voice Detection API",
|
| 98 |
+
"version": "1.0.0",
|
| 99 |
+
"documentation": "/docs",
|
| 100 |
+
"supported_languages": SupportedLanguage.values(),
|
| 101 |
+
}
|
app/api/routes/voice_detection.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Voice detection API endpoint.
|
| 3 |
+
|
| 4 |
+
Main endpoint for detecting AI-generated vs human voice.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter
|
| 8 |
+
from fastapi import HTTPException
|
| 9 |
+
from fastapi import status
|
| 10 |
+
|
| 11 |
+
from app.api.dependencies import ValidatedApiKey
|
| 12 |
+
from app.api.dependencies import VoiceDetectorDep
|
| 13 |
+
from app.models.request import VoiceDetectionRequest
|
| 14 |
+
from app.models.response import ErrorResponse
|
| 15 |
+
from app.models.response import VoiceDetectionResponse
|
| 16 |
+
from app.utils.exceptions import AudioDecodeError
|
| 17 |
+
from app.utils.exceptions import AudioDurationError
|
| 18 |
+
from app.utils.exceptions import AudioFormatError
|
| 19 |
+
from app.utils.exceptions import AudioProcessingError
|
| 20 |
+
from app.utils.exceptions import InferenceError
|
| 21 |
+
from app.utils.exceptions import ModelNotLoadedError
|
| 22 |
+
from app.utils.logger import get_logger
|
| 23 |
+
|
| 24 |
+
logger = get_logger(__name__)
|
| 25 |
+
|
| 26 |
+
router = APIRouter()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.post(
|
| 30 |
+
"/voice-detection",
|
| 31 |
+
response_model=VoiceDetectionResponse,
|
| 32 |
+
response_model_include={"status", "language", "classification", "confidenceScore", "explanation"},
|
| 33 |
+
responses={
|
| 34 |
+
200: {
|
| 35 |
+
"description": "Successful voice detection",
|
| 36 |
+
"model": VoiceDetectionResponse,
|
| 37 |
+
},
|
| 38 |
+
400: {
|
| 39 |
+
"description": "Invalid audio data",
|
| 40 |
+
"model": ErrorResponse,
|
| 41 |
+
},
|
| 42 |
+
401: {
|
| 43 |
+
"description": "Invalid or missing API key",
|
| 44 |
+
"model": ErrorResponse,
|
| 45 |
+
},
|
| 46 |
+
422: {
|
| 47 |
+
"description": "Validation error",
|
| 48 |
+
"model": ErrorResponse,
|
| 49 |
+
},
|
| 50 |
+
429: {
|
| 51 |
+
"description": "Rate limit exceeded",
|
| 52 |
+
"model": ErrorResponse,
|
| 53 |
+
},
|
| 54 |
+
500: {
|
| 55 |
+
"description": "Internal server error",
|
| 56 |
+
"model": ErrorResponse,
|
| 57 |
+
},
|
| 58 |
+
503: {
|
| 59 |
+
"description": "Model not loaded",
|
| 60 |
+
"model": ErrorResponse,
|
| 61 |
+
},
|
| 62 |
+
},
|
| 63 |
+
summary="Detect AI-Generated Voice",
|
| 64 |
+
description="""
|
| 65 |
+
Analyze a voice sample to determine if it's AI-generated or spoken by a human.
|
| 66 |
+
|
| 67 |
+
**Supported Languages:** Tamil, English, Hindi, Malayalam, Telugu
|
| 68 |
+
|
| 69 |
+
**Input Requirements:**
|
| 70 |
+
- Audio must be Base64-encoded MP3
|
| 71 |
+
- Duration: 0.5s to 30s
|
| 72 |
+
- One audio sample per request
|
| 73 |
+
|
| 74 |
+
**Response:**
|
| 75 |
+
- Classification: AI_GENERATED or HUMAN
|
| 76 |
+
- Confidence score: 0.0 to 1.0
|
| 77 |
+
- Human-readable explanation
|
| 78 |
+
""",
|
| 79 |
+
)
|
| 80 |
+
async def detect_voice(
|
| 81 |
+
request: VoiceDetectionRequest,
|
| 82 |
+
voice_detector: VoiceDetectorDep,
|
| 83 |
+
api_key: ValidatedApiKey,
|
| 84 |
+
) -> VoiceDetectionResponse:
|
| 85 |
+
"""
|
| 86 |
+
Detect whether a voice sample is AI-generated or human.
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
request: Voice detection request with audio data
|
| 90 |
+
voice_detector: VoiceDetector service dependency
|
| 91 |
+
api_key: Validated API key from header
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
VoiceDetectionResponse with classification result
|
| 95 |
+
"""
|
| 96 |
+
try:
|
| 97 |
+
result = await voice_detector.detect(
|
| 98 |
+
audio_base64=request.audioBase64,
|
| 99 |
+
language=request.language,
|
| 100 |
+
)
|
| 101 |
+
return result
|
| 102 |
+
|
| 103 |
+
except AudioDecodeError as e:
|
| 104 |
+
logger.warning("Audio decode error", error=str(e))
|
| 105 |
+
raise HTTPException(
|
| 106 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 107 |
+
detail=f"Failed to decode audio: {e.message}",
|
| 108 |
+
) from e
|
| 109 |
+
|
| 110 |
+
except AudioFormatError as e:
|
| 111 |
+
logger.warning("Audio format error", error=str(e))
|
| 112 |
+
raise HTTPException(
|
| 113 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 114 |
+
detail=f"Invalid audio format: {e.message}",
|
| 115 |
+
) from e
|
| 116 |
+
|
| 117 |
+
except AudioDurationError as e:
|
| 118 |
+
logger.warning("Audio duration error", error=str(e), details=e.details)
|
| 119 |
+
raise HTTPException(
|
| 120 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 121 |
+
detail=e.message,
|
| 122 |
+
) from e
|
| 123 |
+
|
| 124 |
+
except AudioProcessingError as e:
|
| 125 |
+
logger.error("Audio processing error", error=str(e))
|
| 126 |
+
raise HTTPException(
|
| 127 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 128 |
+
detail=f"Audio processing failed: {e.message}",
|
| 129 |
+
) from e
|
| 130 |
+
|
| 131 |
+
except ModelNotLoadedError as e:
|
| 132 |
+
logger.error("Model not loaded", error=str(e))
|
| 133 |
+
raise HTTPException(
|
| 134 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 135 |
+
detail="Voice detection model is not available. Please try again later.",
|
| 136 |
+
) from e
|
| 137 |
+
|
| 138 |
+
except InferenceError as e:
|
| 139 |
+
logger.error("Inference error", error=str(e))
|
| 140 |
+
raise HTTPException(
|
| 141 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 142 |
+
detail="Voice analysis failed. Please try again.",
|
| 143 |
+
) from e
|
| 144 |
+
|
| 145 |
+
except Exception as e:
|
| 146 |
+
logger.exception("Unexpected error in voice detection")
|
| 147 |
+
raise HTTPException(
|
| 148 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 149 |
+
detail="Internal server error",
|
| 150 |
+
) from e
|
app/config.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Application configuration using Pydantic Settings.
|
| 3 |
+
|
| 4 |
+
Loads configuration from environment variables and .env file.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from functools import lru_cache
|
| 8 |
+
from typing import Literal
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
from pydantic import Field
|
| 12 |
+
from pydantic import field_validator
|
| 13 |
+
from pydantic_settings import BaseSettings
|
| 14 |
+
from pydantic_settings import SettingsConfigDict
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Settings(BaseSettings):
|
| 18 |
+
"""Application settings loaded from environment variables."""
|
| 19 |
+
|
| 20 |
+
model_config = SettingsConfigDict(
|
| 21 |
+
env_file=".env",
|
| 22 |
+
env_file_encoding="utf-8",
|
| 23 |
+
case_sensitive=False,
|
| 24 |
+
extra="ignore",
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# -------------------------------------------------------------------------
|
| 28 |
+
# Application Settings
|
| 29 |
+
# -------------------------------------------------------------------------
|
| 30 |
+
APP_NAME: str = "VoiceAuth API"
|
| 31 |
+
APP_VERSION: str = "1.0.0"
|
| 32 |
+
DEBUG: bool = False
|
| 33 |
+
HOST: str = "0.0.0.0"
|
| 34 |
+
PORT: int = 8000
|
| 35 |
+
|
| 36 |
+
# -------------------------------------------------------------------------
|
| 37 |
+
# Security Settings
|
| 38 |
+
# -------------------------------------------------------------------------
|
| 39 |
+
API_KEYS: str = Field(
|
| 40 |
+
default="",
|
| 41 |
+
description="Comma-separated list of valid API keys",
|
| 42 |
+
)
|
| 43 |
+
CORS_ORIGINS: str = Field(
|
| 44 |
+
default="http://localhost:3000,http://localhost:8000",
|
| 45 |
+
description="Comma-separated list of allowed CORS origins",
|
| 46 |
+
)
|
| 47 |
+
RATE_LIMIT_REQUESTS: int = Field(default=100, ge=1)
|
| 48 |
+
RATE_LIMIT_PERIOD: int = Field(default=60, ge=1, description="Period in seconds")
|
| 49 |
+
|
| 50 |
+
# -------------------------------------------------------------------------
|
| 51 |
+
# ML Model Settings
|
| 52 |
+
# -------------------------------------------------------------------------
|
| 53 |
+
MODEL_NAME: str = "facebook/wav2vec2-base"
|
| 54 |
+
MODEL_PATH: str = ""
|
| 55 |
+
DEVICE: str = "auto"
|
| 56 |
+
MAX_AUDIO_DURATION: float = Field(default=30.0, ge=1.0)
|
| 57 |
+
MIN_AUDIO_DURATION: float = Field(default=0.5, ge=0.1)
|
| 58 |
+
SAMPLE_RATE: int = Field(default=16000, ge=8000, le=48000)
|
| 59 |
+
|
| 60 |
+
# -------------------------------------------------------------------------
|
| 61 |
+
# Redis Settings
|
| 62 |
+
# -------------------------------------------------------------------------
|
| 63 |
+
REDIS_URL: str = "redis://localhost:6379"
|
| 64 |
+
REDIS_DB: int = 0
|
| 65 |
+
|
| 66 |
+
# -------------------------------------------------------------------------
|
| 67 |
+
# Logging Settings
|
| 68 |
+
# -------------------------------------------------------------------------
|
| 69 |
+
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
|
| 70 |
+
LOG_FORMAT: Literal["json", "console"] = "json"
|
| 71 |
+
|
| 72 |
+
# -------------------------------------------------------------------------
|
| 73 |
+
# Computed Properties
|
| 74 |
+
# -------------------------------------------------------------------------
|
| 75 |
+
@property
|
| 76 |
+
def api_keys_list(self) -> list[str]:
|
| 77 |
+
"""Parse comma-separated API keys into a list."""
|
| 78 |
+
if not self.API_KEYS:
|
| 79 |
+
return []
|
| 80 |
+
return [key.strip() for key in self.API_KEYS.split(",") if key.strip()]
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def cors_origins_list(self) -> list[str]:
|
| 84 |
+
"""Parse comma-separated CORS origins into a list."""
|
| 85 |
+
if not self.CORS_ORIGINS:
|
| 86 |
+
return []
|
| 87 |
+
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()]
|
| 88 |
+
|
| 89 |
+
@property
|
| 90 |
+
def torch_device(self) -> str:
|
| 91 |
+
"""Determine the appropriate torch device."""
|
| 92 |
+
if self.DEVICE == "auto":
|
| 93 |
+
return "cuda" if torch.cuda.is_available() else "cpu"
|
| 94 |
+
return self.DEVICE
|
| 95 |
+
|
| 96 |
+
@property
|
| 97 |
+
def model_identifier(self) -> str:
|
| 98 |
+
"""Get the model path or name to load."""
|
| 99 |
+
return self.MODEL_PATH if self.MODEL_PATH else self.MODEL_NAME
|
| 100 |
+
|
| 101 |
+
# -------------------------------------------------------------------------
|
| 102 |
+
# Validators
|
| 103 |
+
# -------------------------------------------------------------------------
|
| 104 |
+
@field_validator("DEVICE")
|
| 105 |
+
@classmethod
|
| 106 |
+
def validate_device(cls, v: str) -> str:
|
| 107 |
+
"""Validate device configuration."""
|
| 108 |
+
valid_devices = {"auto", "cpu", "cuda", "mps"}
|
| 109 |
+
# Allow cuda:N format
|
| 110 |
+
if v.startswith("cuda:"):
|
| 111 |
+
return v
|
| 112 |
+
if v not in valid_devices:
|
| 113 |
+
raise ValueError(f"Device must be one of {valid_devices} or 'cuda:N' format")
|
| 114 |
+
return v
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@lru_cache
|
| 118 |
+
def get_settings() -> Settings:
|
| 119 |
+
"""
|
| 120 |
+
Get cached settings instance.
|
| 121 |
+
|
| 122 |
+
Uses lru_cache to ensure settings are only loaded once.
|
| 123 |
+
"""
|
| 124 |
+
return Settings()
|
app/main.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VoiceAuth API - Main Application Entry Point.
|
| 3 |
+
|
| 4 |
+
FastAPI application for AI-generated voice detection.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from slowapi import _rate_limit_exceeded_handler
|
| 12 |
+
from slowapi.errors import RateLimitExceeded
|
| 13 |
+
|
| 14 |
+
from app.api.middleware.error_handler import setup_exception_handlers
|
| 15 |
+
from app.api.middleware.rate_limiter import limiter
|
| 16 |
+
from app.api.routes import health
|
| 17 |
+
from app.api.routes import voice_detection
|
| 18 |
+
from app.config import get_settings
|
| 19 |
+
from app.ml.model_loader import ModelLoader
|
| 20 |
+
from app.utils.logger import get_logger
|
| 21 |
+
from app.utils.logger import setup_logging
|
| 22 |
+
|
| 23 |
+
# Initialize logging first
|
| 24 |
+
setup_logging()
|
| 25 |
+
logger = get_logger(__name__)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@asynccontextmanager
|
| 29 |
+
async def lifespan(app: FastAPI):
|
| 30 |
+
"""
|
| 31 |
+
Application lifespan manager.
|
| 32 |
+
|
| 33 |
+
Handles startup and shutdown events:
|
| 34 |
+
- Startup: Load ML model, run warmup
|
| 35 |
+
- Shutdown: Unload model, cleanup
|
| 36 |
+
"""
|
| 37 |
+
# =========================================================================
|
| 38 |
+
# STARTUP
|
| 39 |
+
# =========================================================================
|
| 40 |
+
logger.info("Starting VoiceAuth API...")
|
| 41 |
+
|
| 42 |
+
# Initialize model loader
|
| 43 |
+
model_loader = ModelLoader()
|
| 44 |
+
app.state.model_loader = model_loader
|
| 45 |
+
|
| 46 |
+
# Load ML model
|
| 47 |
+
logger.info("Loading ML model...")
|
| 48 |
+
try:
|
| 49 |
+
await model_loader.load_model_async()
|
| 50 |
+
logger.info("ML model loaded successfully")
|
| 51 |
+
|
| 52 |
+
# Run warmup inference
|
| 53 |
+
logger.info("Running model warmup...")
|
| 54 |
+
model_loader.warmup()
|
| 55 |
+
logger.info("Model warmup complete")
|
| 56 |
+
|
| 57 |
+
except Exception as e:
|
| 58 |
+
logger.error("Failed to load ML model", error=str(e))
|
| 59 |
+
# Continue without model for health checks
|
| 60 |
+
# Actual detection will fail with proper error
|
| 61 |
+
|
| 62 |
+
logger.info("VoiceAuth API is ready!")
|
| 63 |
+
|
| 64 |
+
yield
|
| 65 |
+
|
| 66 |
+
# =========================================================================
|
| 67 |
+
# SHUTDOWN
|
| 68 |
+
# =========================================================================
|
| 69 |
+
logger.info("Shutting down VoiceAuth API...")
|
| 70 |
+
|
| 71 |
+
# Unload model
|
| 72 |
+
if hasattr(app.state, "model_loader"):
|
| 73 |
+
app.state.model_loader.unload_model()
|
| 74 |
+
|
| 75 |
+
logger.info("Shutdown complete")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def create_app() -> FastAPI:
|
| 79 |
+
"""
|
| 80 |
+
Create and configure the FastAPI application.
|
| 81 |
+
|
| 82 |
+
Returns:
|
| 83 |
+
Configured FastAPI application
|
| 84 |
+
"""
|
| 85 |
+
settings = get_settings()
|
| 86 |
+
|
| 87 |
+
# Create FastAPI app
|
| 88 |
+
app = FastAPI(
|
| 89 |
+
title=settings.APP_NAME,
|
| 90 |
+
version=settings.APP_VERSION,
|
| 91 |
+
description="""
|
| 92 |
+
# VoiceAuth - AI Voice Detection API
|
| 93 |
+
|
| 94 |
+
Detect whether a voice sample is **AI-generated** or **human-spoken** across 5 languages.
|
| 95 |
+
|
| 96 |
+
## Supported Languages
|
| 97 |
+
- Tamil
|
| 98 |
+
- English
|
| 99 |
+
- Hindi
|
| 100 |
+
- Malayalam
|
| 101 |
+
- Telugu
|
| 102 |
+
|
| 103 |
+
## Authentication
|
| 104 |
+
All detection requests require an API key in the `x-api-key` header.
|
| 105 |
+
|
| 106 |
+
## Rate Limiting
|
| 107 |
+
Default: 100 requests per minute per API key.
|
| 108 |
+
""",
|
| 109 |
+
lifespan=lifespan,
|
| 110 |
+
docs_url="/docs",
|
| 111 |
+
redoc_url="/redoc",
|
| 112 |
+
openapi_url="/openapi.json",
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
# =========================================================================
|
| 116 |
+
# MIDDLEWARE
|
| 117 |
+
# =========================================================================
|
| 118 |
+
|
| 119 |
+
# CORS
|
| 120 |
+
app.add_middleware(
|
| 121 |
+
CORSMiddleware,
|
| 122 |
+
allow_origins=settings.cors_origins_list or ["*"],
|
| 123 |
+
allow_credentials=True,
|
| 124 |
+
allow_methods=["*"],
|
| 125 |
+
allow_headers=["*"],
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Rate limiting
|
| 129 |
+
app.state.limiter = limiter
|
| 130 |
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 131 |
+
|
| 132 |
+
# =========================================================================
|
| 133 |
+
# EXCEPTION HANDLERS
|
| 134 |
+
# =========================================================================
|
| 135 |
+
setup_exception_handlers(app)
|
| 136 |
+
|
| 137 |
+
# =========================================================================
|
| 138 |
+
# ROUTES
|
| 139 |
+
# =========================================================================
|
| 140 |
+
app.include_router(
|
| 141 |
+
health.router,
|
| 142 |
+
prefix="/api",
|
| 143 |
+
tags=["Health"],
|
| 144 |
+
)
|
| 145 |
+
app.include_router(
|
| 146 |
+
voice_detection.router,
|
| 147 |
+
prefix="/api",
|
| 148 |
+
tags=["Voice Detection"],
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
# Federated Learning routes (Phase 2)
|
| 152 |
+
from app.api.routes import federated
|
| 153 |
+
app.include_router(
|
| 154 |
+
federated.router,
|
| 155 |
+
prefix="/api",
|
| 156 |
+
tags=["Federated Learning"],
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
return app
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# Create application instance
|
| 163 |
+
app = create_app()
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def main() -> None:
|
| 167 |
+
"""Run the application using uvicorn."""
|
| 168 |
+
import uvicorn
|
| 169 |
+
|
| 170 |
+
settings = get_settings()
|
| 171 |
+
|
| 172 |
+
uvicorn.run(
|
| 173 |
+
"app.main:app",
|
| 174 |
+
host=settings.HOST,
|
| 175 |
+
port=settings.PORT,
|
| 176 |
+
reload=settings.DEBUG,
|
| 177 |
+
log_level=settings.LOG_LEVEL.lower(),
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
if __name__ == "__main__":
|
| 182 |
+
main()
|
app/ml/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Machine Learning pipeline package."""
|
| 2 |
+
|
| 3 |
+
from app.ml.inference import InferenceEngine
|
| 4 |
+
from app.ml.model_loader import ModelLoader
|
| 5 |
+
from app.ml.preprocessing import AudioPreprocessor
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"ModelLoader",
|
| 9 |
+
"InferenceEngine",
|
| 10 |
+
"AudioPreprocessor",
|
| 11 |
+
]
|
app/ml/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (435 Bytes). View file
|
|
|
app/ml/__pycache__/inference.cpython-312.pyc
ADDED
|
Binary file (9.4 kB). View file
|
|
|
app/ml/__pycache__/model_loader.cpython-312.pyc
ADDED
|
Binary file (9.92 kB). View file
|
|
|
app/ml/__pycache__/preprocessing.cpython-312.pyc
ADDED
|
Binary file (5.69 kB). View file
|
|
|
app/ml/inference.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Inference engine for voice classification.
|
| 3 |
+
|
| 4 |
+
Handles model inference and result processing.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
from typing import TYPE_CHECKING
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn.functional as F
|
| 12 |
+
|
| 13 |
+
from app.models.enums import Classification
|
| 14 |
+
from app.utils.constants import ID_TO_LABEL
|
| 15 |
+
from app.utils.exceptions import InferenceError
|
| 16 |
+
from app.utils.logger import get_logger
|
| 17 |
+
|
| 18 |
+
if TYPE_CHECKING:
|
| 19 |
+
from transformers import Wav2Vec2ForSequenceClassification
|
| 20 |
+
from transformers import Wav2Vec2Processor
|
| 21 |
+
|
| 22 |
+
logger = get_logger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class InferenceEngine:
|
| 26 |
+
"""
|
| 27 |
+
Inference engine for Wav2Vec2 voice classification.
|
| 28 |
+
|
| 29 |
+
Handles running model inference and converting outputs
|
| 30 |
+
to classification results.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
def __init__(
|
| 34 |
+
self,
|
| 35 |
+
model: "Wav2Vec2ForSequenceClassification",
|
| 36 |
+
processor: "Wav2Vec2Processor",
|
| 37 |
+
device: str = "cpu",
|
| 38 |
+
) -> None:
|
| 39 |
+
"""
|
| 40 |
+
Initialize InferenceEngine.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
model: Loaded Wav2Vec2ForSequenceClassification model
|
| 44 |
+
processor: Wav2Vec2Processor for preprocessing
|
| 45 |
+
device: Device to run inference on
|
| 46 |
+
"""
|
| 47 |
+
self.model = model
|
| 48 |
+
self.processor = processor
|
| 49 |
+
self.device = device
|
| 50 |
+
|
| 51 |
+
def predict(
|
| 52 |
+
self,
|
| 53 |
+
input_tensors: dict[str, torch.Tensor],
|
| 54 |
+
) -> tuple[Classification, float]:
|
| 55 |
+
"""
|
| 56 |
+
Run inference and return classification result.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
input_tensors: Preprocessed input tensors with input_values
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
Tuple of (Classification, confidence_score)
|
| 63 |
+
|
| 64 |
+
Raises:
|
| 65 |
+
InferenceError: If inference fails
|
| 66 |
+
"""
|
| 67 |
+
try:
|
| 68 |
+
start_time = time.perf_counter()
|
| 69 |
+
|
| 70 |
+
# Ensure model is in eval mode
|
| 71 |
+
self.model.eval()
|
| 72 |
+
|
| 73 |
+
# Run inference without gradient computation
|
| 74 |
+
with torch.no_grad():
|
| 75 |
+
outputs = self.model(**input_tensors)
|
| 76 |
+
|
| 77 |
+
# Get logits
|
| 78 |
+
logits = outputs.logits
|
| 79 |
+
|
| 80 |
+
# Apply softmax to get probabilities
|
| 81 |
+
probabilities = F.softmax(logits, dim=-1)
|
| 82 |
+
|
| 83 |
+
# Get predicted class and confidence
|
| 84 |
+
confidence, predicted_class = torch.max(probabilities, dim=-1)
|
| 85 |
+
|
| 86 |
+
# Convert to Python types
|
| 87 |
+
predicted_class_id = predicted_class.item()
|
| 88 |
+
confidence_score = confidence.item()
|
| 89 |
+
|
| 90 |
+
# Get label from model's config or fallback to our mapping
|
| 91 |
+
if hasattr(self.model.config, 'id2label') and self.model.config.id2label:
|
| 92 |
+
model_label = self.model.config.id2label.get(predicted_class_id, "HUMAN")
|
| 93 |
+
# Convert pretrained model labels to standard format
|
| 94 |
+
model_label_lower = str(model_label).lower()
|
| 95 |
+
|
| 96 |
+
ai_keywords = ["fake", "spoof", "synthetic", "ai", "deepfake", "generated"]
|
| 97 |
+
is_ai = any(keyword in model_label_lower for keyword in ai_keywords)
|
| 98 |
+
|
| 99 |
+
if is_ai:
|
| 100 |
+
label = "AI_GENERATED"
|
| 101 |
+
else:
|
| 102 |
+
label = "HUMAN"
|
| 103 |
+
else:
|
| 104 |
+
label = ID_TO_LABEL.get(predicted_class_id, "HUMAN")
|
| 105 |
+
|
| 106 |
+
classification = Classification(label)
|
| 107 |
+
|
| 108 |
+
inference_time_ms = (time.perf_counter() - start_time) * 1000
|
| 109 |
+
|
| 110 |
+
logger.info(
|
| 111 |
+
"Inference complete",
|
| 112 |
+
classification=classification.value,
|
| 113 |
+
confidence=round(confidence_score, 4),
|
| 114 |
+
inference_time_ms=round(inference_time_ms, 2),
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
return classification, confidence_score
|
| 118 |
+
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error("Inference failed", error=str(e))
|
| 121 |
+
raise InferenceError(
|
| 122 |
+
f"Model inference failed: {e}",
|
| 123 |
+
details={"error": str(e)},
|
| 124 |
+
) from e
|
| 125 |
+
|
| 126 |
+
def predict_with_probabilities(
|
| 127 |
+
self,
|
| 128 |
+
input_tensors: dict[str, torch.Tensor],
|
| 129 |
+
) -> dict:
|
| 130 |
+
"""
|
| 131 |
+
Run inference and return full probability distribution.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
input_tensors: Preprocessed input tensors
|
| 135 |
+
|
| 136 |
+
Returns:
|
| 137 |
+
Dictionary with classification, confidence, and all probabilities
|
| 138 |
+
"""
|
| 139 |
+
try:
|
| 140 |
+
self.model.eval()
|
| 141 |
+
|
| 142 |
+
with torch.no_grad():
|
| 143 |
+
outputs = self.model(**input_tensors)
|
| 144 |
+
|
| 145 |
+
logits = outputs.logits
|
| 146 |
+
probabilities = F.softmax(logits, dim=-1)
|
| 147 |
+
|
| 148 |
+
# Get all probabilities
|
| 149 |
+
probs = probabilities.squeeze().cpu().numpy()
|
| 150 |
+
|
| 151 |
+
# Get predicted class
|
| 152 |
+
confidence, predicted_class = torch.max(probabilities, dim=-1)
|
| 153 |
+
predicted_class_id = predicted_class.item()
|
| 154 |
+
label = ID_TO_LABEL.get(predicted_class_id, "HUMAN")
|
| 155 |
+
|
| 156 |
+
return {
|
| 157 |
+
"classification": Classification(label),
|
| 158 |
+
"confidence": float(confidence.item()),
|
| 159 |
+
"probabilities": {
|
| 160 |
+
"HUMAN": float(probs[0]) if len(probs) > 0 else 0.0,
|
| 161 |
+
"AI_GENERATED": float(probs[1]) if len(probs) > 1 else 0.0,
|
| 162 |
+
},
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
except Exception as e:
|
| 166 |
+
logger.error("Inference with probabilities failed", error=str(e))
|
| 167 |
+
raise InferenceError(
|
| 168 |
+
f"Model inference failed: {e}",
|
| 169 |
+
details={"error": str(e)},
|
| 170 |
+
) from e
|
| 171 |
+
|
| 172 |
+
def batch_predict(
|
| 173 |
+
self,
|
| 174 |
+
input_tensors: dict[str, torch.Tensor],
|
| 175 |
+
) -> list[tuple[Classification, float]]:
|
| 176 |
+
"""
|
| 177 |
+
Run batch inference.
|
| 178 |
+
|
| 179 |
+
Args:
|
| 180 |
+
input_tensors: Batched preprocessed input tensors
|
| 181 |
+
|
| 182 |
+
Returns:
|
| 183 |
+
List of (Classification, confidence) tuples
|
| 184 |
+
"""
|
| 185 |
+
try:
|
| 186 |
+
self.model.eval()
|
| 187 |
+
|
| 188 |
+
with torch.no_grad():
|
| 189 |
+
outputs = self.model(**input_tensors)
|
| 190 |
+
|
| 191 |
+
logits = outputs.logits
|
| 192 |
+
probabilities = F.softmax(logits, dim=-1)
|
| 193 |
+
|
| 194 |
+
results = []
|
| 195 |
+
for i in range(probabilities.shape[0]):
|
| 196 |
+
confidence, predicted_class = torch.max(probabilities[i], dim=-1)
|
| 197 |
+
predicted_class_id = predicted_class.item()
|
| 198 |
+
label = ID_TO_LABEL.get(predicted_class_id, "HUMAN")
|
| 199 |
+
results.append((Classification(label), float(confidence.item())))
|
| 200 |
+
|
| 201 |
+
return results
|
| 202 |
+
|
| 203 |
+
except Exception as e:
|
| 204 |
+
logger.error("Batch inference failed", error=str(e))
|
| 205 |
+
raise InferenceError(
|
| 206 |
+
f"Batch inference failed: {e}",
|
| 207 |
+
details={"error": str(e)},
|
| 208 |
+
) from e
|
| 209 |
+
|
| 210 |
+
def get_hidden_states(
|
| 211 |
+
self,
|
| 212 |
+
input_tensors: dict[str, torch.Tensor],
|
| 213 |
+
) -> torch.Tensor:
|
| 214 |
+
"""
|
| 215 |
+
Extract hidden states for explainability.
|
| 216 |
+
|
| 217 |
+
Args:
|
| 218 |
+
input_tensors: Preprocessed input tensors
|
| 219 |
+
|
| 220 |
+
Returns:
|
| 221 |
+
Hidden state tensor from last layer
|
| 222 |
+
"""
|
| 223 |
+
self.model.eval()
|
| 224 |
+
|
| 225 |
+
with torch.no_grad():
|
| 226 |
+
outputs = self.model(
|
| 227 |
+
**input_tensors,
|
| 228 |
+
output_hidden_states=True,
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
# Return last hidden state
|
| 232 |
+
if hasattr(outputs, "hidden_states") and outputs.hidden_states:
|
| 233 |
+
return outputs.hidden_states[-1]
|
| 234 |
+
|
| 235 |
+
return torch.tensor([])
|
app/ml/model_loader.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Model loader for Wav2Vec2 voice classification model.
|
| 3 |
+
|
| 4 |
+
Handles loading, caching, and management of the ML model.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import gc
|
| 8 |
+
import threading
|
| 9 |
+
from typing import TYPE_CHECKING
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
from transformers import Wav2Vec2ForSequenceClassification
|
| 13 |
+
from transformers import Wav2Vec2Processor
|
| 14 |
+
|
| 15 |
+
from app.config import get_settings
|
| 16 |
+
from app.utils.constants import ID_TO_LABEL
|
| 17 |
+
from app.utils.constants import LABEL_TO_ID
|
| 18 |
+
from app.utils.exceptions import ModelNotLoadedError
|
| 19 |
+
from app.utils.logger import get_logger
|
| 20 |
+
|
| 21 |
+
if TYPE_CHECKING:
|
| 22 |
+
from transformers import PreTrainedModel
|
| 23 |
+
|
| 24 |
+
logger = get_logger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ModelLoader:
|
| 28 |
+
"""
|
| 29 |
+
Singleton model loader for Wav2Vec2 classification model.
|
| 30 |
+
|
| 31 |
+
Handles lazy loading, caching, and memory management of the ML model.
|
| 32 |
+
Thread-safe for production use.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
_instance: "ModelLoader | None" = None
|
| 36 |
+
_lock: threading.Lock = threading.Lock()
|
| 37 |
+
|
| 38 |
+
def __new__(cls) -> "ModelLoader":
|
| 39 |
+
"""Ensure only one instance exists (Singleton pattern)."""
|
| 40 |
+
if cls._instance is None:
|
| 41 |
+
with cls._lock:
|
| 42 |
+
if cls._instance is None:
|
| 43 |
+
cls._instance = super().__new__(cls)
|
| 44 |
+
cls._instance._initialized = False
|
| 45 |
+
return cls._instance
|
| 46 |
+
|
| 47 |
+
def __init__(self) -> None:
|
| 48 |
+
"""Initialize ModelLoader if not already initialized."""
|
| 49 |
+
if getattr(self, "_initialized", False):
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
self.settings = get_settings()
|
| 53 |
+
self.model: Wav2Vec2ForSequenceClassification | None = None
|
| 54 |
+
self.processor: Wav2Vec2Processor | None = None
|
| 55 |
+
self.device: str = self.settings.torch_device
|
| 56 |
+
self._model_lock = threading.Lock()
|
| 57 |
+
self._initialized = True
|
| 58 |
+
|
| 59 |
+
logger.info(
|
| 60 |
+
"ModelLoader initialized",
|
| 61 |
+
device=self.device,
|
| 62 |
+
model_identifier=self.settings.model_identifier,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def is_loaded(self) -> bool:
|
| 67 |
+
"""Check if model is loaded and ready for inference."""
|
| 68 |
+
return self.model is not None and self.processor is not None
|
| 69 |
+
|
| 70 |
+
def load_model(self) -> None:
|
| 71 |
+
"""
|
| 72 |
+
Load the Wav2Vec2 model and processor.
|
| 73 |
+
|
| 74 |
+
Thread-safe loading with proper error handling.
|
| 75 |
+
|
| 76 |
+
Raises:
|
| 77 |
+
Exception: If model loading fails
|
| 78 |
+
"""
|
| 79 |
+
with self._model_lock:
|
| 80 |
+
if self.is_loaded:
|
| 81 |
+
logger.debug("Model already loaded, skipping")
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
model_identifier = self.settings.model_identifier
|
| 85 |
+
|
| 86 |
+
logger.info("Loading Wav2Vec2 model", model=model_identifier, device=self.device)
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
# Load processor - try model first, fallback to base wav2vec2
|
| 90 |
+
try:
|
| 91 |
+
self.processor = Wav2Vec2Processor.from_pretrained(
|
| 92 |
+
model_identifier,
|
| 93 |
+
trust_remote_code=False,
|
| 94 |
+
)
|
| 95 |
+
except Exception:
|
| 96 |
+
# Fine-tuned models often don't have processor, use base
|
| 97 |
+
logger.info("Using base wav2vec2 processor")
|
| 98 |
+
self.processor = Wav2Vec2Processor.from_pretrained(
|
| 99 |
+
"facebook/wav2vec2-base",
|
| 100 |
+
trust_remote_code=False,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# Load model with classification head
|
| 104 |
+
# For pretrained deepfake models, use their existing configuration
|
| 105 |
+
self.model = Wav2Vec2ForSequenceClassification.from_pretrained(
|
| 106 |
+
model_identifier,
|
| 107 |
+
trust_remote_code=False,
|
| 108 |
+
ignore_mismatched_sizes=True, # Allow different classifier sizes
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# Move model to device
|
| 112 |
+
self.model = self.model.to(self.device)
|
| 113 |
+
|
| 114 |
+
# Set to evaluation mode
|
| 115 |
+
self.model.eval()
|
| 116 |
+
|
| 117 |
+
# Log memory usage
|
| 118 |
+
if self.device.startswith("cuda"):
|
| 119 |
+
memory_allocated = torch.cuda.memory_allocated() / (1024**3)
|
| 120 |
+
logger.info(
|
| 121 |
+
"Model loaded successfully",
|
| 122 |
+
device=self.device,
|
| 123 |
+
gpu_memory_gb=round(memory_allocated, 2),
|
| 124 |
+
)
|
| 125 |
+
else:
|
| 126 |
+
logger.info("Model loaded successfully", device=self.device)
|
| 127 |
+
|
| 128 |
+
except Exception as e:
|
| 129 |
+
self.model = None
|
| 130 |
+
self.processor = None
|
| 131 |
+
logger.error("Failed to load model", error=str(e))
|
| 132 |
+
raise
|
| 133 |
+
|
| 134 |
+
async def load_model_async(self) -> None:
|
| 135 |
+
"""
|
| 136 |
+
Async wrapper for model loading.
|
| 137 |
+
|
| 138 |
+
Useful for FastAPI lifespan context.
|
| 139 |
+
"""
|
| 140 |
+
# Run in thread pool to avoid blocking
|
| 141 |
+
import asyncio
|
| 142 |
+
|
| 143 |
+
loop = asyncio.get_event_loop()
|
| 144 |
+
await loop.run_in_executor(None, self.load_model)
|
| 145 |
+
|
| 146 |
+
def get_model(self) -> tuple[Wav2Vec2ForSequenceClassification, Wav2Vec2Processor]:
|
| 147 |
+
"""
|
| 148 |
+
Get the loaded model and processor.
|
| 149 |
+
|
| 150 |
+
Returns:
|
| 151 |
+
Tuple of (model, processor)
|
| 152 |
+
|
| 153 |
+
Raises:
|
| 154 |
+
ModelNotLoadedError: If model is not loaded
|
| 155 |
+
"""
|
| 156 |
+
if not self.is_loaded:
|
| 157 |
+
raise ModelNotLoadedError(
|
| 158 |
+
"Model not loaded. Call load_model() first.",
|
| 159 |
+
details={"model_identifier": self.settings.model_identifier},
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
return self.model, self.processor # type: ignore
|
| 163 |
+
|
| 164 |
+
def unload_model(self) -> None:
|
| 165 |
+
"""
|
| 166 |
+
Unload model and free memory.
|
| 167 |
+
|
| 168 |
+
Useful for memory management in constrained environments.
|
| 169 |
+
"""
|
| 170 |
+
with self._model_lock:
|
| 171 |
+
if self.model is not None:
|
| 172 |
+
del self.model
|
| 173 |
+
self.model = None
|
| 174 |
+
|
| 175 |
+
if self.processor is not None:
|
| 176 |
+
del self.processor
|
| 177 |
+
self.processor = None
|
| 178 |
+
|
| 179 |
+
# Force garbage collection
|
| 180 |
+
gc.collect()
|
| 181 |
+
|
| 182 |
+
# Clear CUDA cache if using GPU
|
| 183 |
+
if torch.cuda.is_available():
|
| 184 |
+
torch.cuda.empty_cache()
|
| 185 |
+
|
| 186 |
+
logger.info("Model unloaded, memory freed")
|
| 187 |
+
|
| 188 |
+
def warmup(self) -> None:
|
| 189 |
+
"""
|
| 190 |
+
Run a warmup inference to initialize CUDA kernels.
|
| 191 |
+
|
| 192 |
+
This reduces latency on the first real inference.
|
| 193 |
+
"""
|
| 194 |
+
if not self.is_loaded:
|
| 195 |
+
logger.warning("Cannot warmup - model not loaded")
|
| 196 |
+
return
|
| 197 |
+
|
| 198 |
+
logger.info("Running model warmup...")
|
| 199 |
+
|
| 200 |
+
try:
|
| 201 |
+
# Create dummy input
|
| 202 |
+
dummy_audio = torch.randn(1, 16000) # 1 second of audio
|
| 203 |
+
|
| 204 |
+
model, processor = self.get_model()
|
| 205 |
+
|
| 206 |
+
# Preprocess dummy audio
|
| 207 |
+
inputs = processor(
|
| 208 |
+
dummy_audio.squeeze().numpy(),
|
| 209 |
+
sampling_rate=16000,
|
| 210 |
+
return_tensors="pt",
|
| 211 |
+
padding=True,
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
| 215 |
+
|
| 216 |
+
# Run warmup inference
|
| 217 |
+
with torch.no_grad():
|
| 218 |
+
_ = model(**inputs)
|
| 219 |
+
|
| 220 |
+
logger.info("Model warmup complete")
|
| 221 |
+
|
| 222 |
+
except Exception as e:
|
| 223 |
+
logger.warning("Warmup failed (non-critical)", error=str(e))
|
| 224 |
+
|
| 225 |
+
def health_check(self) -> dict:
|
| 226 |
+
"""
|
| 227 |
+
Get model health status.
|
| 228 |
+
|
| 229 |
+
Returns:
|
| 230 |
+
Dictionary with health information
|
| 231 |
+
"""
|
| 232 |
+
status = {
|
| 233 |
+
"model_loaded": self.is_loaded,
|
| 234 |
+
"device": self.device,
|
| 235 |
+
"model_identifier": self.settings.model_identifier,
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
if self.device.startswith("cuda") and torch.cuda.is_available():
|
| 239 |
+
status["gpu_memory_allocated_gb"] = round(
|
| 240 |
+
torch.cuda.memory_allocated() / (1024**3), 2
|
| 241 |
+
)
|
| 242 |
+
status["gpu_memory_reserved_gb"] = round(
|
| 243 |
+
torch.cuda.memory_reserved() / (1024**3), 2
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
return status
|
app/ml/preprocessing.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Audio preprocessing for Wav2Vec2 model.
|
| 3 |
+
|
| 4 |
+
Handles conversion from audio arrays to model input tensors.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
from transformers import Wav2Vec2Processor
|
| 10 |
+
|
| 11 |
+
from app.utils.constants import TARGET_SAMPLE_RATE
|
| 12 |
+
from app.utils.logger import get_logger
|
| 13 |
+
|
| 14 |
+
logger = get_logger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class AudioPreprocessor:
|
| 18 |
+
"""
|
| 19 |
+
Preprocessor for preparing audio data for Wav2Vec2 model.
|
| 20 |
+
|
| 21 |
+
Converts numpy audio arrays into the tensor format expected
|
| 22 |
+
by the Wav2Vec2ForSequenceClassification model.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
processor: Wav2Vec2Processor,
|
| 28 |
+
device: str = "cpu",
|
| 29 |
+
) -> None:
|
| 30 |
+
"""
|
| 31 |
+
Initialize AudioPreprocessor.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
processor: Wav2Vec2Processor instance
|
| 35 |
+
device: Target device for tensors (cpu/cuda)
|
| 36 |
+
"""
|
| 37 |
+
self.processor = processor
|
| 38 |
+
self.device = device
|
| 39 |
+
self.sample_rate = TARGET_SAMPLE_RATE
|
| 40 |
+
|
| 41 |
+
def validate_input(self, audio_array: np.ndarray) -> bool:
|
| 42 |
+
"""
|
| 43 |
+
Validate audio array for processing.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
audio_array: Input audio array
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
True if valid
|
| 50 |
+
|
| 51 |
+
Raises:
|
| 52 |
+
ValueError: If validation fails
|
| 53 |
+
"""
|
| 54 |
+
if not isinstance(audio_array, np.ndarray):
|
| 55 |
+
raise ValueError(f"Expected numpy array, got {type(audio_array)}")
|
| 56 |
+
|
| 57 |
+
if audio_array.ndim != 1:
|
| 58 |
+
raise ValueError(f"Expected 1D array, got {audio_array.ndim}D")
|
| 59 |
+
|
| 60 |
+
if len(audio_array) == 0:
|
| 61 |
+
raise ValueError("Audio array is empty")
|
| 62 |
+
|
| 63 |
+
if np.isnan(audio_array).any():
|
| 64 |
+
raise ValueError("Audio array contains NaN values")
|
| 65 |
+
|
| 66 |
+
if np.isinf(audio_array).any():
|
| 67 |
+
raise ValueError("Audio array contains infinite values")
|
| 68 |
+
|
| 69 |
+
return True
|
| 70 |
+
|
| 71 |
+
def preprocess(
|
| 72 |
+
self,
|
| 73 |
+
audio_array: np.ndarray,
|
| 74 |
+
return_attention_mask: bool = True,
|
| 75 |
+
) -> dict[str, torch.Tensor]:
|
| 76 |
+
"""
|
| 77 |
+
Preprocess audio array for model inference.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
audio_array: 1D numpy array of audio samples (16kHz, normalized)
|
| 81 |
+
return_attention_mask: Whether to return attention mask
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
Dictionary with input_values and optionally attention_mask
|
| 85 |
+
"""
|
| 86 |
+
# Validate input
|
| 87 |
+
self.validate_input(audio_array)
|
| 88 |
+
|
| 89 |
+
# Ensure float32
|
| 90 |
+
audio_array = audio_array.astype(np.float32)
|
| 91 |
+
|
| 92 |
+
# Process through Wav2Vec2Processor
|
| 93 |
+
inputs = self.processor(
|
| 94 |
+
audio_array,
|
| 95 |
+
sampling_rate=self.sample_rate,
|
| 96 |
+
return_tensors="pt",
|
| 97 |
+
padding=True,
|
| 98 |
+
return_attention_mask=return_attention_mask,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Move to target device
|
| 102 |
+
inputs = {key: value.to(self.device) for key, value in inputs.items()}
|
| 103 |
+
|
| 104 |
+
logger.debug(
|
| 105 |
+
"Audio preprocessed for model",
|
| 106 |
+
input_length=inputs["input_values"].shape[-1],
|
| 107 |
+
device=self.device,
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
return inputs
|
| 111 |
+
|
| 112 |
+
def preprocess_batch(
|
| 113 |
+
self,
|
| 114 |
+
audio_arrays: list[np.ndarray],
|
| 115 |
+
return_attention_mask: bool = True,
|
| 116 |
+
) -> dict[str, torch.Tensor]:
|
| 117 |
+
"""
|
| 118 |
+
Preprocess a batch of audio arrays.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
audio_arrays: List of 1D numpy arrays
|
| 122 |
+
return_attention_mask: Whether to return attention mask
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
Dictionary with batched input_values and optionally attention_mask
|
| 126 |
+
"""
|
| 127 |
+
# Validate all inputs
|
| 128 |
+
for i, audio in enumerate(audio_arrays):
|
| 129 |
+
try:
|
| 130 |
+
self.validate_input(audio)
|
| 131 |
+
except ValueError as e:
|
| 132 |
+
raise ValueError(f"Invalid audio at index {i}: {e}") from e
|
| 133 |
+
|
| 134 |
+
# Ensure float32
|
| 135 |
+
audio_arrays = [audio.astype(np.float32) for audio in audio_arrays]
|
| 136 |
+
|
| 137 |
+
# Process batch through Wav2Vec2Processor
|
| 138 |
+
inputs = self.processor(
|
| 139 |
+
audio_arrays,
|
| 140 |
+
sampling_rate=self.sample_rate,
|
| 141 |
+
return_tensors="pt",
|
| 142 |
+
padding=True,
|
| 143 |
+
return_attention_mask=return_attention_mask,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
# Move to target device
|
| 147 |
+
inputs = {key: value.to(self.device) for key, value in inputs.items()}
|
| 148 |
+
|
| 149 |
+
logger.debug(
|
| 150 |
+
"Batch preprocessed for model",
|
| 151 |
+
batch_size=len(audio_arrays),
|
| 152 |
+
device=self.device,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
return inputs
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic models package."""
|
| 2 |
+
|
| 3 |
+
from app.models.enums import AudioFormat
|
| 4 |
+
from app.models.enums import Classification
|
| 5 |
+
from app.models.enums import SupportedLanguage
|
| 6 |
+
from app.models.request import VoiceDetectionRequest
|
| 7 |
+
from app.models.response import ErrorResponse
|
| 8 |
+
from app.models.response import HealthResponse
|
| 9 |
+
from app.models.response import VoiceDetectionResponse
|
| 10 |
+
|
| 11 |
+
__all__ = [
|
| 12 |
+
"SupportedLanguage",
|
| 13 |
+
"Classification",
|
| 14 |
+
"AudioFormat",
|
| 15 |
+
"VoiceDetectionRequest",
|
| 16 |
+
"VoiceDetectionResponse",
|
| 17 |
+
"ErrorResponse",
|
| 18 |
+
"HealthResponse",
|
| 19 |
+
]
|
app/models/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (612 Bytes). View file
|
|
|
app/models/__pycache__/enums.cpython-312.pyc
ADDED
|
Binary file (2.39 kB). View file
|
|
|
app/models/__pycache__/request.cpython-312.pyc
ADDED
|
Binary file (3.49 kB). View file
|
|
|
app/models/__pycache__/response.cpython-312.pyc
ADDED
|
Binary file (7.04 kB). View file
|
|
|
app/models/enums.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Enumeration types for VoiceAuth API.
|
| 3 |
+
|
| 4 |
+
Defines supported languages, classification results, and audio formats.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from enum import Enum
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SupportedLanguage(str, Enum):
|
| 11 |
+
"""
|
| 12 |
+
Supported languages for voice detection.
|
| 13 |
+
|
| 14 |
+
The API supports these 5 Indian languages for AI voice detection.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
TAMIL = "Tamil"
|
| 18 |
+
ENGLISH = "English"
|
| 19 |
+
HINDI = "Hindi"
|
| 20 |
+
MALAYALAM = "Malayalam"
|
| 21 |
+
TELUGU = "Telugu"
|
| 22 |
+
|
| 23 |
+
@classmethod
|
| 24 |
+
def values(cls) -> list[str]:
|
| 25 |
+
"""Get all language values as a list."""
|
| 26 |
+
return [lang.value for lang in cls]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class Classification(str, Enum):
|
| 30 |
+
"""
|
| 31 |
+
Voice classification result.
|
| 32 |
+
|
| 33 |
+
Indicates whether the detected voice is AI-generated or human.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
AI_GENERATED = "AI_GENERATED"
|
| 37 |
+
HUMAN = "HUMAN"
|
| 38 |
+
|
| 39 |
+
@property
|
| 40 |
+
def is_synthetic(self) -> bool:
|
| 41 |
+
"""Check if classification indicates synthetic voice."""
|
| 42 |
+
return self == Classification.AI_GENERATED
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class AudioFormat(str, Enum):
|
| 46 |
+
"""
|
| 47 |
+
Supported audio input formats.
|
| 48 |
+
|
| 49 |
+
Currently only MP3 is supported as per competition requirements.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
MP3 = "mp3"
|
| 53 |
+
|
| 54 |
+
@classmethod
|
| 55 |
+
def values(cls) -> list[str]:
|
| 56 |
+
"""Get all format values as a list."""
|
| 57 |
+
return [fmt.value for fmt in cls]
|
app/models/request.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Request models for VoiceAuth API.
|
| 3 |
+
|
| 4 |
+
Defines Pydantic models for API request validation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import base64
|
| 8 |
+
import re
|
| 9 |
+
from typing import Annotated
|
| 10 |
+
|
| 11 |
+
from pydantic import BaseModel
|
| 12 |
+
from pydantic import ConfigDict
|
| 13 |
+
from pydantic import Field
|
| 14 |
+
from pydantic import field_validator
|
| 15 |
+
|
| 16 |
+
from app.models.enums import AudioFormat
|
| 17 |
+
from app.models.enums import SupportedLanguage
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class VoiceDetectionRequest(BaseModel):
|
| 21 |
+
"""
|
| 22 |
+
Request model for voice detection endpoint.
|
| 23 |
+
|
| 24 |
+
Accepts Base64-encoded MP3 audio in one of 5 supported languages.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
model_config = ConfigDict(
|
| 28 |
+
json_schema_extra={
|
| 29 |
+
"example": {
|
| 30 |
+
"language": "Tamil",
|
| 31 |
+
"audioFormat": "mp3",
|
| 32 |
+
"audioBase64": "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAA...",
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
language: Annotated[
|
| 38 |
+
SupportedLanguage,
|
| 39 |
+
Field(
|
| 40 |
+
description="Language of the audio content. Must be one of: Tamil, English, Hindi, Malayalam, Telugu"
|
| 41 |
+
),
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
audioFormat: Annotated[
|
| 45 |
+
AudioFormat,
|
| 46 |
+
Field(
|
| 47 |
+
default=AudioFormat.MP3,
|
| 48 |
+
description="Format of the audio file. Currently only 'mp3' is supported",
|
| 49 |
+
),
|
| 50 |
+
] = AudioFormat.MP3
|
| 51 |
+
|
| 52 |
+
audioBase64: Annotated[
|
| 53 |
+
str,
|
| 54 |
+
Field(
|
| 55 |
+
min_length=100,
|
| 56 |
+
description="Base64-encoded MP3 audio data. Minimum 100 characters for valid audio",
|
| 57 |
+
),
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
@field_validator("audioBase64")
|
| 61 |
+
@classmethod
|
| 62 |
+
def validate_base64(cls, v: str) -> str:
|
| 63 |
+
"""
|
| 64 |
+
Validate that the string is valid Base64.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
v: The base64 string to validate
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
The validated base64 string
|
| 71 |
+
|
| 72 |
+
Raises:
|
| 73 |
+
ValueError: If the string is not valid base64
|
| 74 |
+
"""
|
| 75 |
+
# Remove any whitespace
|
| 76 |
+
v = v.strip()
|
| 77 |
+
|
| 78 |
+
# Check for valid base64 characters
|
| 79 |
+
base64_pattern = re.compile(r"^[A-Za-z0-9+/]*={0,2}$")
|
| 80 |
+
if not base64_pattern.match(v):
|
| 81 |
+
raise ValueError("Invalid Base64 encoding: contains invalid characters")
|
| 82 |
+
|
| 83 |
+
# Try to decode to verify it's valid base64
|
| 84 |
+
try:
|
| 85 |
+
# Add padding if needed
|
| 86 |
+
padding = 4 - len(v) % 4
|
| 87 |
+
if padding != 4:
|
| 88 |
+
v += "=" * padding
|
| 89 |
+
|
| 90 |
+
decoded = base64.b64decode(v)
|
| 91 |
+
if len(decoded) < 100:
|
| 92 |
+
raise ValueError("Decoded audio data is too small to be a valid MP3 file")
|
| 93 |
+
|
| 94 |
+
except Exception as e:
|
| 95 |
+
if "Invalid Base64" in str(e) or "too small" in str(e):
|
| 96 |
+
raise
|
| 97 |
+
raise ValueError(f"Invalid Base64 encoding: {e}") from e
|
| 98 |
+
|
| 99 |
+
return v.rstrip("=") + "=" * (4 - len(v.rstrip("=")) % 4) if len(v.rstrip("=")) % 4 else v
|
app/models/response.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Response models for VoiceAuth API.
|
| 3 |
+
|
| 4 |
+
Defines Pydantic models for API responses.
|
| 5 |
+
|
| 6 |
+
PHASE 1 ENHANCED: Includes Risk Score, Quality Score, Temporal Analysis.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Annotated
|
| 10 |
+
from typing import Any
|
| 11 |
+
from typing import Literal
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
from pydantic import BaseModel
|
| 15 |
+
from pydantic import ConfigDict
|
| 16 |
+
from pydantic import Field
|
| 17 |
+
|
| 18 |
+
from app.models.enums import Classification
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class VoiceDetectionResponse(BaseModel):
|
| 22 |
+
"""
|
| 23 |
+
Successful voice detection response.
|
| 24 |
+
|
| 25 |
+
Contains classification result, confidence score, explanation,
|
| 26 |
+
and comprehensive analysis data.
|
| 27 |
+
|
| 28 |
+
PHASE 1 FEATURES:
|
| 29 |
+
- deepfakeRiskScore: Business-friendly risk rating
|
| 30 |
+
- audioQuality: Input quality assessment
|
| 31 |
+
- temporalAnalysis: Breathing, pauses, rhythm analysis
|
| 32 |
+
- audioForensics: Spectral and energy analysis
|
| 33 |
+
- performanceMetrics: Processing time breakdown
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
model_config = ConfigDict(
|
| 37 |
+
json_schema_extra={
|
| 38 |
+
"example": {
|
| 39 |
+
"status": "success",
|
| 40 |
+
"language": "Tamil",
|
| 41 |
+
"classification": "AI_GENERATED",
|
| 42 |
+
"confidenceScore": 0.91,
|
| 43 |
+
"explanation": "Strong evidence of AI-generated speech: absence of natural breathing sounds and mechanically consistent pause patterns detected",
|
| 44 |
+
"deepfakeRiskScore": {
|
| 45 |
+
"score": 87,
|
| 46 |
+
"level": "HIGH",
|
| 47 |
+
"recommendation": "Manual review required before approval",
|
| 48 |
+
},
|
| 49 |
+
"audioQuality": {
|
| 50 |
+
"score": 85,
|
| 51 |
+
"rating": "GOOD",
|
| 52 |
+
"reliability": "High confidence in detection results",
|
| 53 |
+
},
|
| 54 |
+
"temporalAnalysis": {
|
| 55 |
+
"breathingDetected": False,
|
| 56 |
+
"breathingNaturalness": 0.0,
|
| 57 |
+
"pauseMechanicalScore": 0.78,
|
| 58 |
+
"rhythmConsistency": 0.85,
|
| 59 |
+
"anomalyScore": 0.72,
|
| 60 |
+
"verdict": "HIGH_ANOMALY",
|
| 61 |
+
},
|
| 62 |
+
"audioForensics": {
|
| 63 |
+
"spectralCentroid": 1523.45,
|
| 64 |
+
"pitchStability": 0.89,
|
| 65 |
+
"jitter": 0.0021,
|
| 66 |
+
"energyConsistency": 0.92,
|
| 67 |
+
"silenceRatio": 0.08,
|
| 68 |
+
"aiLikelihood": 0.76,
|
| 69 |
+
},
|
| 70 |
+
"performanceMetrics": {
|
| 71 |
+
"audioProcessingMs": 45.23,
|
| 72 |
+
"forensicsAnalysisMs": 12.87,
|
| 73 |
+
"temporalAnalysisMs": 8.45,
|
| 74 |
+
"modelInferenceMs": 127.45,
|
| 75 |
+
"totalProcessingMs": 193.00,
|
| 76 |
+
},
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
status: Annotated[
|
| 82 |
+
Literal["success"],
|
| 83 |
+
Field(description="Response status, always 'success' for successful detections"),
|
| 84 |
+
] = "success"
|
| 85 |
+
|
| 86 |
+
language: Annotated[
|
| 87 |
+
str,
|
| 88 |
+
Field(description="Language of the analyzed audio"),
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
classification: Annotated[
|
| 92 |
+
Classification,
|
| 93 |
+
Field(description="Classification result: AI_GENERATED or HUMAN"),
|
| 94 |
+
]
|
| 95 |
+
|
| 96 |
+
confidenceScore: Annotated[
|
| 97 |
+
float,
|
| 98 |
+
Field(
|
| 99 |
+
ge=0.0,
|
| 100 |
+
le=1.0,
|
| 101 |
+
description="Calibrated confidence score between 0.0 and 1.0",
|
| 102 |
+
),
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
explanation: Annotated[
|
| 106 |
+
str,
|
| 107 |
+
Field(
|
| 108 |
+
max_length=250,
|
| 109 |
+
description="Human-readable explanation based on comprehensive analysis",
|
| 110 |
+
),
|
| 111 |
+
]
|
| 112 |
+
|
| 113 |
+
# NEW: Deepfake Risk Score
|
| 114 |
+
deepfakeRiskScore: Annotated[
|
| 115 |
+
Optional[dict[str, Any]],
|
| 116 |
+
Field(
|
| 117 |
+
default=None,
|
| 118 |
+
description="Business-friendly risk score (0-100) with level and recommendation",
|
| 119 |
+
),
|
| 120 |
+
] = None
|
| 121 |
+
|
| 122 |
+
# NEW: Audio Quality Score
|
| 123 |
+
audioQuality: Annotated[
|
| 124 |
+
Optional[dict[str, Any]],
|
| 125 |
+
Field(
|
| 126 |
+
default=None,
|
| 127 |
+
description="Input audio quality assessment affecting detection reliability",
|
| 128 |
+
),
|
| 129 |
+
] = None
|
| 130 |
+
|
| 131 |
+
# NEW: Temporal Analysis
|
| 132 |
+
temporalAnalysis: Annotated[
|
| 133 |
+
Optional[dict[str, Any]],
|
| 134 |
+
Field(
|
| 135 |
+
default=None,
|
| 136 |
+
description="Temporal anomaly analysis (breathing, pauses, rhythm)",
|
| 137 |
+
),
|
| 138 |
+
] = None
|
| 139 |
+
|
| 140 |
+
# Audio Forensics
|
| 141 |
+
audioForensics: Annotated[
|
| 142 |
+
Optional[dict[str, float]],
|
| 143 |
+
Field(
|
| 144 |
+
default=None,
|
| 145 |
+
description="Detailed audio forensics analysis metrics",
|
| 146 |
+
),
|
| 147 |
+
] = None
|
| 148 |
+
|
| 149 |
+
# Performance Metrics
|
| 150 |
+
performanceMetrics: Annotated[
|
| 151 |
+
Optional[dict[str, float]],
|
| 152 |
+
Field(
|
| 153 |
+
default=None,
|
| 154 |
+
description="Performance timing breakdown",
|
| 155 |
+
),
|
| 156 |
+
] = None
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
class ErrorResponse(BaseModel):
|
| 160 |
+
"""
|
| 161 |
+
Error response model.
|
| 162 |
+
|
| 163 |
+
Returned when the API encounters an error.
|
| 164 |
+
"""
|
| 165 |
+
|
| 166 |
+
model_config = ConfigDict(
|
| 167 |
+
json_schema_extra={
|
| 168 |
+
"example": {
|
| 169 |
+
"status": "error",
|
| 170 |
+
"message": "Invalid API key or malformed request",
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
status: Annotated[
|
| 176 |
+
Literal["error"],
|
| 177 |
+
Field(description="Response status, always 'error' for error responses"),
|
| 178 |
+
] = "error"
|
| 179 |
+
|
| 180 |
+
message: Annotated[
|
| 181 |
+
str,
|
| 182 |
+
Field(description="Human-readable error message"),
|
| 183 |
+
]
|
| 184 |
+
|
| 185 |
+
details: Annotated[
|
| 186 |
+
Optional[dict[str, Any]],
|
| 187 |
+
Field(default=None, description="Additional error details if available"),
|
| 188 |
+
] = None
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class HealthResponse(BaseModel):
|
| 192 |
+
"""
|
| 193 |
+
Health check response model.
|
| 194 |
+
|
| 195 |
+
Returned by health check endpoints.
|
| 196 |
+
"""
|
| 197 |
+
|
| 198 |
+
model_config = ConfigDict(
|
| 199 |
+
json_schema_extra={
|
| 200 |
+
"example": {
|
| 201 |
+
"status": "healthy",
|
| 202 |
+
"version": "1.0.0",
|
| 203 |
+
"model_loaded": True,
|
| 204 |
+
"model_name": "facebook/wav2vec2-base",
|
| 205 |
+
"device": "cuda",
|
| 206 |
+
"supported_languages": ["Tamil", "English", "Hindi", "Malayalam", "Telugu"],
|
| 207 |
+
"features": [
|
| 208 |
+
"audio_forensics",
|
| 209 |
+
"temporal_anomaly_detection",
|
| 210 |
+
"deepfake_risk_score",
|
| 211 |
+
"audio_quality_score",
|
| 212 |
+
],
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
status: Annotated[
|
| 218 |
+
str,
|
| 219 |
+
Field(description="Health status: 'healthy' or 'unhealthy'"),
|
| 220 |
+
]
|
| 221 |
+
|
| 222 |
+
version: Annotated[
|
| 223 |
+
str,
|
| 224 |
+
Field(description="API version"),
|
| 225 |
+
]
|
| 226 |
+
|
| 227 |
+
model_loaded: Annotated[
|
| 228 |
+
bool,
|
| 229 |
+
Field(description="Whether the ML model is loaded and ready"),
|
| 230 |
+
]
|
| 231 |
+
|
| 232 |
+
model_name: Annotated[
|
| 233 |
+
Optional[str],
|
| 234 |
+
Field(default=None, description="Name of the loaded model"),
|
| 235 |
+
] = None
|
| 236 |
+
|
| 237 |
+
device: Annotated[
|
| 238 |
+
Optional[str],
|
| 239 |
+
Field(default=None, description="Device used for inference (cpu/cuda)"),
|
| 240 |
+
] = None
|
| 241 |
+
|
| 242 |
+
supported_languages: Annotated[
|
| 243 |
+
list[str],
|
| 244 |
+
Field(description="List of supported languages"),
|
| 245 |
+
]
|
| 246 |
+
|
| 247 |
+
features: Annotated[
|
| 248 |
+
Optional[list[str]],
|
| 249 |
+
Field(default=None, description="List of enabled features"),
|
| 250 |
+
] = None
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
class LanguagesResponse(BaseModel):
|
| 254 |
+
"""Response model for supported languages endpoint."""
|
| 255 |
+
|
| 256 |
+
languages: Annotated[
|
| 257 |
+
list[str],
|
| 258 |
+
Field(description="List of supported language names"),
|
| 259 |
+
]
|
| 260 |
+
|
| 261 |
+
count: Annotated[
|
| 262 |
+
int,
|
| 263 |
+
Field(description="Number of supported languages"),
|
| 264 |
+
]
|
app/services/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Services package."""
|
| 2 |
+
|
| 3 |
+
from app.services.audio_forensics import AudioForensicsAnalyzer
|
| 4 |
+
from app.services.audio_processor import AudioProcessor
|
| 5 |
+
from app.services.explainability import ExplainabilityService
|
| 6 |
+
from app.services.score_calculators import AudioQualityScorer
|
| 7 |
+
from app.services.score_calculators import RiskScoreCalculator
|
| 8 |
+
from app.services.temporal_detector import TemporalAnomalyDetector
|
| 9 |
+
from app.services.voice_detector import VoiceDetector
|
| 10 |
+
|
| 11 |
+
__all__ = [
|
| 12 |
+
"AudioProcessor",
|
| 13 |
+
"AudioForensicsAnalyzer",
|
| 14 |
+
"ExplainabilityService",
|
| 15 |
+
"TemporalAnomalyDetector",
|
| 16 |
+
"RiskScoreCalculator",
|
| 17 |
+
"AudioQualityScorer",
|
| 18 |
+
"VoiceDetector",
|
| 19 |
+
]
|
app/services/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (748 Bytes). View file
|
|
|
app/services/__pycache__/audio_forensics.cpython-312.pyc
ADDED
|
Binary file (13.8 kB). View file
|
|
|
app/services/__pycache__/audio_processor.cpython-312.pyc
ADDED
|
Binary file (10.7 kB). View file
|
|
|
app/services/__pycache__/explainability.cpython-312.pyc
ADDED
|
Binary file (6.76 kB). View file
|
|
|
app/services/__pycache__/federated_learning.cpython-312.pyc
ADDED
|
Binary file (10.1 kB). View file
|
|
|
app/services/__pycache__/score_calculators.cpython-312.pyc
ADDED
|
Binary file (7.86 kB). View file
|
|
|