honey126/VoxAI
0
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 