CoolFace
Apppublic

honey126/VoxAI

sourceHugging Facemitupdated 9mo agoView on Hugging Face
0likes
api_server.py649 linesDownload Raw Back to root
1"""2FastAPI REST API Server for F5-TTS3Provides HTTP endpoints for React/web applications to generate speech4"""5 6from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Depends7from fastapi.responses import FileResponse, Response8from fastapi.middleware.cors import CORSMiddleware9from pydantic import BaseModel10from sqlalchemy.orm import Session11import tempfile12import os13import uvicorn14from pathlib import Path15import tqdm16import jwt17import asyncio18import logging19from concurrent.futures import ThreadPoolExecutor20from functools import partial21 22from f5_tts.api import F5TTS23 24# Import backend modules25from backend.config import settings26from backend.database import get_db, init_db27from backend.models.user import User28from backend.models.voice import Voice29from backend.schemas.auth import UserCreate, UserLogin, Token, TokenRefresh, UserResponse30from backend.schemas.tts import HealthResponse31from backend.schemas.voice import VoiceCreate, VoiceUpdate, VoiceResponse32from backend.auth.password import hash_password, verify_password33from backend.auth.jwt import create_access_token, create_refresh_token, decode_token, verify_token_type34from backend.auth.dependencies import get_current_user, get_optional_current_user35from backend.exceptions import (36    APIException,37    ValidationException,38    AuthenticationException,39    TTSGenerationException,40    ResourceNotFoundException41)42 43# Configure logging44logging.basicConfig(45    level=logging.INFO,46    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'47)48logger = logging.getLogger(__name__)49 50# Create thread pool executor for CPU-intensive TTS operations51# Using 2 workers to handle multiple requests without overloading CPU52executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="tts_worker")53 54# Initialize FastAPI app55app = FastAPI(56    title=settings.APP_NAME,57    description="Production-ready zero-shot voice cloning Text-to-Speech API with authentication",58    version=settings.APP_VERSION59)60 61# Configure CORS from settings62app.add_middleware(63    CORSMiddleware,64    allow_origins=settings.cors_origins_list,65    allow_credentials=True,66    allow_methods=["*"],67    allow_headers=["*"],68)69 70 71# Exception handlers72@app.exception_handler(APIException)73async def api_exception_handler(request, exc: APIException):74    """Handle custom API exceptions"""75    return Response(76        content=str(exc.detail),77        status_code=exc.status_code,78        media_type="application/json"79    )80 81 82# Startup event83@app.on_event("startup")84async def startup_event():85    """Initialize database on startup"""86    try:87        init_db()88        logger.info("Database initialized successfully")89    except Exception as e:90        logger.warning(f"Database initialization failed: {e}")91        logger.warning("If using a fresh database, run Alembic migrations first")92 93# Shutdown event94@app.on_event("shutdown")95async def shutdown_event():96    """Cleanup resources on shutdown"""97    logger.info("Shutting down thread pool executor...")98    executor.shutdown(wait=True)99    logger.info("Shutdown complete")100 101# Initialize F5-TTS model (loads once at startup)102logger.info("Loading F5-TTS model...")103tts_engine = F5TTS(model="F5TTS_Base")104logger.info(f"Model loaded successfully on device: {tts_engine.device}")105logger.info(f"Target sample rate: {tts_engine.target_sample_rate} Hz")106logger.info(f"Mel spectrogram type: {tts_engine.mel_spec_type}")107 108 109# Function to get the F5-TTS model instance (for Gradio interface)110def get_f5tts_model():111    """Return the initialized F5-TTS model instance"""112    return tts_engine113 114 115# Helper function for non-blocking TTS inference116def run_tts_inference(117    ref_file: str,118    ref_text: str,119    gen_text: str,120    output_file: str,121    **kwargs122):123    """124    Synchronous wrapper for TTS inference to run in thread pool.125    This prevents blocking the FastAPI event loop.126    """127    logger.info(f"Starting TTS inference for text: {gen_text[:50]}...")128    try:129        wav, sr, spec = tts_engine.infer(130            ref_file=ref_file,131            ref_text=ref_text,132            gen_text=gen_text,133            file_wave=output_file,134            show_info=lambda _: None,  # Suppress print output135            progress=tqdm,136            **kwargs137        )138        logger.info(f"TTS inference completed successfully. Sample rate: {sr}")139        return wav, sr, spec140    except Exception as e:141        logger.error(f"TTS inference failed: {str(e)}")142        raise143 144 145# Pydantic models for request/response146class TTSRequest(BaseModel):147    ref_text: str148    gen_text: str149    remove_silence: bool = False150    target_rms: float = 0.1151    cross_fade_duration: float = 0.15152    speed: float = 1.0153    nfe_step: int = 32154    cfg_strength: float = 2.0155    sway_sampling_coef: float = -1.0156    seed: int = -1157 158 159class TTSResponse(BaseModel):160    audio_url: str161    seed: int162    sample_rate: int163    message: str164 165 166class HealthResponse(BaseModel):167    status: str168    model_type: str169    device: str170 171 172# Root endpoint is handled by hf_app.py to serve React frontend173# API info is available at /docs instead174 175@app.get("/health", response_model=HealthResponse)176async def health_check():177    """Health check endpoint"""178    return {179        "status": "healthy",180        "model_type": "F5-TTS",181        "device": str(tts_engine.device)182    }183 184 185# ============================================================================186# Authentication Endpoints187# ============================================================================188 189@app.post("/api/auth/register", response_model=Token, tags=["auth"])190async def register(user_data: UserCreate, db: Session = Depends(get_db)):191    """192    Register a new user account.193 194    Returns JWT access and refresh tokens upon successful registration.195    """196    # Check if user already exists197    existing_user = db.query(User).filter(User.email == user_data.email).first()198    if existing_user:199        raise ValidationException(200            message="User already exists",201            details=[{"field": "email", "message": "Email already registered"}]202        )203 204    # Create new user205    new_user = User(206        email=user_data.email,207        hashed_password=hash_password(user_data.password),208        usage_tier="free"209    )210    db.add(new_user)211    db.commit()212    db.refresh(new_user)213 214    # Create tokens215    access_token = create_access_token(data={"sub": str(new_user.id)})216    refresh_token = create_refresh_token(data={"sub": str(new_user.id)})217 218    return Token(219        access_token=access_token,220        refresh_token=refresh_token,221        token_type="bearer"222    )223 224 225@app.post("/api/auth/login", response_model=Token, tags=["auth"])226async def login(credentials: UserLogin, db: Session = Depends(get_db)):227    """228    Login with email and password.229 230    Returns JWT access and refresh tokens upon successful authentication.231    """232    # Find user by email233    user = db.query(User).filter(User.email == credentials.email).first()234    if not user:235        raise AuthenticationException("Invalid email or password")236 237    # Verify password238    if not verify_password(credentials.password, user.hashed_password):239        raise AuthenticationException("Invalid email or password")240 241    # Check if user is active242    if not user.is_active:243        raise AuthenticationException("User account is deactivated")244 245    # Create tokens246    access_token = create_access_token(data={"sub": str(user.id)})247    refresh_token = create_refresh_token(data={"sub": str(user.id)})248 249    return Token(250        access_token=access_token,251        refresh_token=refresh_token,252        token_type="bearer"253    )254 255 256@app.post("/api/auth/refresh", response_model=Token, tags=["auth"])257async def refresh_token_endpoint(token_data: TokenRefresh, db: Session = Depends(get_db)):258    """259    Refresh access token using refresh token.260 261    Returns new access and refresh tokens.262    """263    try:264        payload = decode_token(token_data.refresh_token)265 266        # Verify token type267        if not verify_token_type(payload, "refresh"):268            raise AuthenticationException("Invalid token type")269 270        user_id = payload.get("sub")271        if not user_id:272            raise AuthenticationException("Invalid token")273 274        # Verify user exists and is active275        user = db.query(User).filter(User.id == user_id).first()276        if not user or not user.is_active:277            raise AuthenticationException("User not found or inactive")278 279        # Create new tokens280        access_token = create_access_token(data={"sub": str(user.id)})281        refresh_token = create_refresh_token(data={"sub": str(user.id)})282 283        return Token(284            access_token=access_token,285            refresh_token=refresh_token,286            token_type="bearer"287        )288 289    except jwt.ExpiredSignatureError:290        raise AuthenticationException("Refresh token has expired")291    except jwt.InvalidTokenError:292        raise AuthenticationException("Invalid refresh token")293 294 295@app.post("/api/auth/logout", tags=["auth"])296async def logout(current_user: User = Depends(get_current_user)):297    """298    Logout (client should discard tokens).299 300    Note: JWT tokens cannot be truly invalidated without a token blacklist.301    Client should discard tokens on logout.302    """303    return {"message": "Logged out successfully"}304 305 306@app.get("/api/auth/me", response_model=UserResponse, tags=["auth"])307async def get_current_user_info(current_user: User = Depends(get_current_user)):308    """309    Get current authenticated user information.310    """311    return current_user312 313 314# ============================================================================315# TTS Endpoints316# ============================================================================317 318 319@app.post("/api/tts", response_model=TTSResponse)320async def generate_speech(321    ref_audio: UploadFile = File(..., description="Reference audio file (WAV, MP3, etc.)"),322    ref_text: str = Form("", description="Transcription of the reference audio (optional - leave empty for auto-detection)"),323    gen_text: str = Form(..., description="Text to generate in the reference voice"),324    remove_silence: bool = Form(False, description="Remove silence from generated audio"),325    target_rms: float = Form(0.1, description="Target RMS level for audio normalization"),326    cross_fade_duration: float = Form(0.15, description="Cross-fade duration between chunks"),327    speed: float = Form(1.0, description="Speech speed multiplier"),328    nfe_step: int = Form(32, description="Number of function evaluations (quality vs speed)"),329    cfg_strength: float = Form(2.0, description="Classifier-free guidance strength"),330    sway_sampling_coef: float = Form(-1.0, description="Sway sampling coefficient (-1 = disabled)"),331    seed: int = Form(-1, description="Random seed (-1 = random)")332):333    """334    Generate speech using F5-TTS voice cloning335 336    Upload a reference audio file and provide the text to generate.337    The generated audio will match the voice characteristics of the reference.338    """339 340    # Create temporary files for processing341    temp_ref_audio = None342    temp_output_audio = None343 344    try:345        # Save uploaded reference audio to temporary file346        temp_ref_audio = tempfile.NamedTemporaryFile(delete=False, suffix=Path(ref_audio.filename).suffix)347        content = await ref_audio.read()348        temp_ref_audio.write(content)349        temp_ref_audio.close()350 351        logger.info(f"Saved reference audio: {temp_ref_audio.name} ({len(content)} bytes)")352 353        # Create temporary output file354        temp_output_audio = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")355        temp_output_audio.close()356 357        # If ref_text is empty, the F5TTS API will auto-transcribe using Whisper358        # DO NOT set a dummy text - let the API handle transcription for best quality359        if not ref_text or ref_text.strip() == "":360            logger.info("No ref_text provided - F5TTS will auto-transcribe the reference audio")361        else:362            logger.info(f"Using provided ref_text: {len(ref_text)} chars")363 364        logger.info(f"TTS request - Gen text: {len(gen_text)} chars, NFE steps: {nfe_step}")365 366        # Generate speech using thread pool executor to avoid blocking event loop367        # This is CRITICAL for performance - runs CPU-intensive task in separate thread368        loop = asyncio.get_event_loop()369        _, sr, _ = await loop.run_in_executor(370            executor,371            partial(372                run_tts_inference,373                ref_file=temp_ref_audio.name,374                ref_text=ref_text,375                gen_text=gen_text,376                output_file=temp_output_audio.name,377                target_rms=target_rms,378                cross_fade_duration=cross_fade_duration,379                speed=speed,380                nfe_step=nfe_step,381                cfg_strength=cfg_strength,382                sway_sampling_coef=sway_sampling_coef,383                remove_silence=remove_silence,384                seed=seed385            )386        )387 388        # Read audio into memory before cleanup389        with open(temp_output_audio.name, 'rb') as f:390            audio_bytes = f.read()391 392        logger.info(f"Successfully generated audio: {len(audio_bytes)} bytes, sample rate: {sr} Hz")393 394        # Return response with audio bytes395        return Response(396            content=audio_bytes,397            media_type="audio/wav",398            headers={399                "Content-Disposition": "attachment; filename=generated_speech.wav",400                "X-Seed": str(tts_engine.seed),401                "X-Sample-Rate": str(sr)402            }403        )404 405    except asyncio.CancelledError:406        logger.warning("TTS request was cancelled by client")407        raise408    except Exception as e:409        logger.error(f"Speech generation failed: {str(e)}", exc_info=True)410        raise TTSGenerationException(f"Speech generation failed: {str(e)}")411 412    finally:413        # Cleanup both temporary files414        if temp_ref_audio and os.path.exists(temp_ref_audio.name):415            try:416                os.unlink(temp_ref_audio.name)417            except Exception:418                pass419 420        if temp_output_audio and os.path.exists(temp_output_audio.name):421            try:422                os.unlink(temp_output_audio.name)423            except Exception:424                pass425 426 427@app.post("/api/tts/quick", response_model=TTSResponse)428async def generate_speech_quick(429    ref_audio: UploadFile = File(...),430    ref_text: str = Form(...),431    gen_text: str = Form(...)432):433    """434    Quick speech generation with default parameters435    Simplified endpoint for basic use cases436    """437    return await generate_speech(438        ref_audio=ref_audio,439        ref_text=ref_text,440        gen_text=gen_text,441        remove_silence=False,442        target_rms=0.1,443        cross_fade_duration=0.15,444        speed=1.0,445        nfe_step=32,446        cfg_strength=2.0,447        sway_sampling_coef=-1.0,448        seed=-1449    )450 451 452# ============================================================================453# Voice Management Endpoints454# ============================================================================455 456@app.post("/api/voices", response_model=VoiceResponse, tags=["voices"])457async def create_voice(458    audio_file: UploadFile = File(..., description="Reference audio file for voice cloning"),459    name: str = Form(..., description="Name for the voice clone"),460    ref_text: str = Form("", description="Transcription of the reference audio (optional)"),461    current_user: User = Depends(get_current_user),462    db: Session = Depends(get_db)463):464    """465    Upload and create a new voice clone.466 467    The reference audio file will be stored and used for future TTS generation.468    """469    import uuid470 471    # Create storage directory if it doesn't exist472    storage_path = Path(settings.STORAGE_PATH) / "voices" / str(current_user.id)473    storage_path.mkdir(parents=True, exist_ok=True)474 475    # Generate unique filename476    voice_id = uuid.uuid4()477    file_extension = Path(audio_file.filename).suffix or ".wav"478    audio_filename = f"{voice_id}{file_extension}"479    audio_path = storage_path / audio_filename480 481    try:482        # Save uploaded audio file483        content = await audio_file.read()484        with open(audio_path, "wb") as f:485            f.write(content)486 487        # Create voice record in database488        new_voice = Voice(489            id=voice_id,490            user_id=current_user.id,491            name=name,492            ref_audio_path=str(audio_path),493            ref_text=ref_text if ref_text else "Sample voice recording",494            is_public=False495        )496        db.add(new_voice)497        db.commit()498        db.refresh(new_voice)499 500        return new_voice501 502    except Exception as e:503        # Cleanup uploaded file if database operation fails504        if audio_path.exists():505            audio_path.unlink()506        raise TTSGenerationException(f"Failed to create voice: {str(e)}")507 508 509@app.get("/api/voices", response_model=list[VoiceResponse], tags=["voices"])510async def list_voices(511    current_user: User = Depends(get_current_user),512    db: Session = Depends(get_db)513):514    """515    List all voice clones for the current user.516    """517    voices = db.query(Voice).filter(Voice.user_id == current_user.id).order_by(Voice.created_at.desc()).all()518    return voices519 520 521@app.get("/api/voices/{voice_id}", response_model=VoiceResponse, tags=["voices"])522async def get_voice(523    voice_id: str,524    current_user: User = Depends(get_current_user),525    db: Session = Depends(get_db)526):527    """528    Get details of a specific voice clone.529    """530    voice = db.query(Voice).filter(531        Voice.id == voice_id,532        Voice.user_id == current_user.id533    ).first()534 535    if not voice:536        raise ResourceNotFoundException("Voice not found")537 538    return voice539 540 541@app.delete("/api/voices/{voice_id}", tags=["voices"])542async def delete_voice(543    voice_id: str,544    current_user: User = Depends(get_current_user),545    db: Session = Depends(get_db)546):547    """548    Delete a voice clone and its associated audio file.549    """550    voice = db.query(Voice).filter(551        Voice.id == voice_id,552        Voice.user_id == current_user.id553    ).first()554 555    if not voice:556        raise ResourceNotFoundException("Voice not found")557 558    # Delete audio file from storage559    audio_path = Path(voice.ref_audio_path)560    if audio_path.exists():561        try:562            audio_path.unlink()563        except Exception as e:564            print(f"Warning: Failed to delete audio file: {e}")565 566    # Delete voice record from database567    db.delete(voice)568    db.commit()569 570    return {"message": "Voice deleted successfully"}571 572 573@app.patch("/api/voices/{voice_id}", response_model=VoiceResponse, tags=["voices"])574async def update_voice(575    voice_id: str,576    name: str = Form(..., description="New name for the voice"),577    current_user: User = Depends(get_current_user),578    db: Session = Depends(get_db)579):580    """581    Update voice metadata (currently only name).582    """583    voice = db.query(Voice).filter(584        Voice.id == voice_id,585        Voice.user_id == current_user.id586    ).first()587 588    if not voice:589        raise ResourceNotFoundException("Voice not found")590 591    voice.name = name592    db.commit()593    db.refresh(voice)594 595    return voice596 597 598@app.get("/api/voices/audio/{user_id}/{filename}", tags=["voices"])599async def get_voice_audio(600    user_id: str,601    filename: str,602    current_user: User = Depends(get_current_user)603):604    """605    Retrieve the reference audio file for a voice.606    Only the owner can access their voice audio files.607    """608    # Verify the requesting user owns this voice609    if str(current_user.id) != user_id:610        raise HTTPException(status_code=403, detail="Access denied")611 612    audio_path = Path(settings.STORAGE_PATH) / "voices" / user_id / filename613 614    if not audio_path.exists():615        raise ResourceNotFoundException("Audio file not found")616 617    return FileResponse(618        path=audio_path,619        media_type="audio/wav",620        filename=filename621    )622 623 624if __name__ == "__main__":625    # Run the API server626    print("\n" + "="*60)627    print("๐Ÿš€ Starting F5-TTS API Server")628    print("="*60)629    print(f"๐Ÿ“ API Documentation: http://localhost:8000/docs")630    print(f"๐Ÿ“ Health Check: http://localhost:8000/health")631    print(f"๐Ÿ“ API Endpoint: http://localhost:8000/api/tts")632    print("="*60)633    print(f"โš™๏ธ  Configuration:")634    print(f"   - Thread pool workers: 2")635    print(f"   - Timeout keep-alive: 300s (5 minutes)")636    print(f"   - Max concurrency: 10 requests")637    print("="*60 + "\n")638 639    uvicorn.run(640        app,641        host="0.0.0.0",642        port=8000,643        log_level="info",644        timeout_keep_alive=300,  # 5 minutes for long-running TTS generation645        timeout_notify=30,  # Notify client after 30s of waiting646        limit_concurrency=10,  # Max concurrent connections647        backlog=100  # Queue size for pending connections648    )649