""" FastAPI application for voicebox backend. Handles voice cloning, generation history, and server mode. """ from fastapi import FastAPI, Depends, UploadFile, File, Form, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session from typing import List, Optional from datetime import datetime import uvicorn import argparse import torch import tempfile import io from pathlib import Path import uuid from . import database, models, profiles, history, tts, transcribe, config, export_import from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile from .utils.progress import get_progress_manager from .utils.tasks import get_task_manager app = FastAPI( title="voicebox API", description="Production-quality Qwen3-TTS voice cloning API", version="0.1.0", ) # CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["*"], # Configure appropriately for production allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ============================================ # ROOT & HEALTH ENDPOINTS # ============================================ @app.get("/") async def root(): """Root endpoint.""" return {"message": "voicebox API", "version": "0.1.0"} @app.get("/health", response_model=models.HealthResponse) async def health(): """Health check endpoint.""" from huggingface_hub import hf_hub_download from pathlib import Path import os tts_model = tts.get_tts_model() gpu_available = torch.cuda.is_available() vram_used = None if gpu_available: vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB # Check if model is loaded - use the same logic as model status endpoint model_loaded = False model_size = None try: # Use the same check as model status endpoint if tts_model.is_loaded(): model_loaded = True # Get the actual loaded model size # Check _current_model_size first (more reliable for actually loaded models) model_size = getattr(tts_model, '_current_model_size', None) if not model_size: # Fallback to model_size attribute (which should be set when model loads) model_size = getattr(tts_model, 'model_size', None) except Exception: # If there's an error checking, assume not loaded model_loaded = False model_size = None # Check if default model is downloaded (cached) model_downloaded = None try: # Check if the default model (1.7B) is cached default_model_id = "Qwen/Qwen3-TTS-12Hz-1.7B-Base" # Method 1: Try scan_cache_dir if available try: from huggingface_hub import scan_cache_dir cache_info = scan_cache_dir() for repo in cache_info.repos: if repo.repo_id == default_model_id: model_downloaded = True break except (ImportError, Exception): # Method 2: Check cache directory cache_dir = os.path.expanduser("~/.cache/huggingface/hub") repo_cache = Path(cache_dir) / "models--" + default_model_id.replace("/", "--") if repo_cache.exists(): has_model_files = ( any(repo_cache.rglob("*.bin")) or any(repo_cache.rglob("*.safetensors")) or any(repo_cache.rglob("*.pt")) or any(repo_cache.rglob("*.pth")) ) model_downloaded = has_model_files except Exception: pass return models.HealthResponse( status="healthy", model_loaded=model_loaded, model_downloaded=model_downloaded, model_size=model_size, gpu_available=gpu_available, vram_used_mb=vram_used, ) # ============================================ # VOICE PROFILE ENDPOINTS # ============================================ @app.post("/profiles", response_model=models.VoiceProfileResponse) async def create_profile( data: models.VoiceProfileCreate, db: Session = Depends(get_db), ): """Create a new voice profile.""" try: return await profiles.create_profile(data, db) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) @app.get("/profiles", response_model=List[models.VoiceProfileResponse]) async def list_profiles(db: Session = Depends(get_db)): """List all voice profiles.""" return await profiles.list_profiles(db) @app.post("/profiles/import", response_model=models.VoiceProfileResponse) async def import_profile( file: UploadFile = File(...), db: Session = Depends(get_db), ): """Import a voice profile from a ZIP archive.""" # Validate file size (max 100MB) MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB # Read file content content = await file.read() if len(content) > MAX_FILE_SIZE: raise HTTPException( status_code=400, detail=f"File too large. Maximum size is {MAX_FILE_SIZE / (1024 * 1024)}MB" ) try: profile = await export_import.import_profile_from_zip(content, db) return profile except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/profiles/{profile_id}", response_model=models.VoiceProfileResponse) async def get_profile( profile_id: str, db: Session = Depends(get_db), ): """Get a voice profile by ID.""" profile = await profiles.get_profile(profile_id, db) if not profile: raise HTTPException(status_code=404, detail="Profile not found") return profile @app.put("/profiles/{profile_id}", response_model=models.VoiceProfileResponse) async def update_profile( profile_id: str, data: models.VoiceProfileCreate, db: Session = Depends(get_db), ): """Update a voice profile.""" profile = await profiles.update_profile(profile_id, data, db) if not profile: raise HTTPException(status_code=404, detail="Profile not found") return profile @app.delete("/profiles/{profile_id}") async def delete_profile( profile_id: str, db: Session = Depends(get_db), ): """Delete a voice profile.""" success = await profiles.delete_profile(profile_id, db) if not success: raise HTTPException(status_code=404, detail="Profile not found") return {"message": "Profile deleted successfully"} @app.post("/profiles/{profile_id}/samples", response_model=models.ProfileSampleResponse) async def add_profile_sample( profile_id: str, file: UploadFile = File(...), reference_text: str = Form(...), db: Session = Depends(get_db), ): """Add a sample to a voice profile.""" # Save uploaded file to temporary location with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: content = await file.read() tmp.write(content) tmp_path = tmp.name try: sample = await profiles.add_profile_sample( profile_id, tmp_path, reference_text, db, ) return sample except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) finally: # Clean up temp file Path(tmp_path).unlink(missing_ok=True) @app.get("/profiles/{profile_id}/samples", response_model=List[models.ProfileSampleResponse]) async def get_profile_samples( profile_id: str, db: Session = Depends(get_db), ): """Get all samples for a profile.""" return await profiles.get_profile_samples(profile_id, db) @app.delete("/profiles/samples/{sample_id}") async def delete_profile_sample( sample_id: str, db: Session = Depends(get_db), ): """Delete a profile sample.""" success = await profiles.delete_profile_sample(sample_id, db) if not success: raise HTTPException(status_code=404, detail="Sample not found") return {"message": "Sample deleted successfully"} @app.get("/profiles/{profile_id}/export") async def export_profile( profile_id: str, db: Session = Depends(get_db), ): """Export a voice profile as a ZIP archive.""" try: # Get profile to get name for filename profile = await profiles.get_profile(profile_id, db) if not profile: raise HTTPException(status_code=404, detail="Profile not found") # Export to ZIP zip_bytes = export_import.export_profile_to_zip(profile_id, db) # Create safe filename safe_name = "".join(c for c in profile.name if c.isalnum() or c in (' ', '-', '_')).strip() if not safe_name: safe_name = "profile" filename = f"profile-{safe_name}.voicebox.zip" # Return as streaming response return StreamingResponse( io.BytesIO(zip_bytes), media_type="application/zip", headers={ "Content-Disposition": f'attachment; filename="{filename}"' } ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ============================================ # GENERATION ENDPOINTS # ============================================ @app.post("/generate", response_model=models.GenerationResponse) async def generate_speech( data: models.GenerationRequest, db: Session = Depends(get_db), ): """Generate speech from text using a voice profile.""" task_manager = get_task_manager() generation_id = str(uuid.uuid4()) try: # Start tracking generation task_manager.start_generation( task_id=generation_id, profile_id=data.profile_id, text=data.text, ) # Get profile profile = await profiles.get_profile(data.profile_id, db) if not profile: raise HTTPException(status_code=404, detail="Profile not found") # Create voice prompt from profile voice_prompt = await profiles.create_voice_prompt_for_profile( data.profile_id, db, ) # Generate audio tts_model = tts.get_tts_model() # Load the requested model size if different from current (async to not block) model_size = data.model_size or "1.7B" await tts_model.load_model_async(model_size) audio, sample_rate = await tts_model.generate( data.text, voice_prompt, data.language, data.seed, data.instruct, ) # Calculate duration duration = len(audio) / sample_rate # Save audio audio_path = config.get_generations_dir() / f"{generation_id}.wav" from .utils.audio import save_audio save_audio(audio, str(audio_path), sample_rate) # Create history entry generation = await history.create_generation( profile_id=data.profile_id, text=data.text, language=data.language, audio_path=str(audio_path), duration=duration, seed=data.seed, db=db, instruct=data.instruct, ) # Mark generation as complete task_manager.complete_generation(generation_id) return generation except ValueError as e: task_manager.complete_generation(generation_id) raise HTTPException(status_code=400, detail=str(e)) except Exception as e: task_manager.complete_generation(generation_id) raise HTTPException(status_code=500, detail=str(e)) # ============================================ # HISTORY ENDPOINTS # ============================================ @app.get("/history", response_model=models.HistoryListResponse) async def list_history( profile_id: Optional[str] = None, search: Optional[str] = None, limit: int = 50, offset: int = 0, db: Session = Depends(get_db), ): """List generation history with optional filters.""" query = models.HistoryQuery( profile_id=profile_id, search=search, limit=limit, offset=offset, ) return await history.list_generations(query, db) @app.get("/history/stats") async def get_stats(db: Session = Depends(get_db)): """Get generation statistics.""" return await history.get_generation_stats(db) @app.post("/history/import") async def import_generation( file: UploadFile = File(...), db: Session = Depends(get_db), ): """Import a generation from a ZIP archive.""" # Validate file size (max 50MB) MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB # Read file content content = await file.read() if len(content) > MAX_FILE_SIZE: raise HTTPException( status_code=400, detail=f"File too large. Maximum size is {MAX_FILE_SIZE / (1024 * 1024)}MB" ) try: result = await export_import.import_generation_from_zip(content, db) return result except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/history/{generation_id}", response_model=models.HistoryResponse) async def get_generation( generation_id: str, db: Session = Depends(get_db), ): """Get a generation by ID.""" # Get generation with profile name result = db.query( DBGeneration, DBVoiceProfile.name.label('profile_name') ).join( DBVoiceProfile, DBGeneration.profile_id == DBVoiceProfile.id ).filter( DBGeneration.id == generation_id ).first() if not result: raise HTTPException(status_code=404, detail="Generation not found") gen, profile_name = result return models.HistoryResponse( id=gen.id, profile_id=gen.profile_id, profile_name=profile_name, text=gen.text, language=gen.language, audio_path=gen.audio_path, duration=gen.duration, seed=gen.seed, instruct=gen.instruct, created_at=gen.created_at, ) @app.delete("/history/{generation_id}") async def delete_generation( generation_id: str, db: Session = Depends(get_db), ): """Delete a generation.""" success = await history.delete_generation(generation_id, db) if not success: raise HTTPException(status_code=404, detail="Generation not found") return {"message": "Generation deleted successfully"} @app.get("/history/{generation_id}/export") async def export_generation( generation_id: str, db: Session = Depends(get_db), ): """Export a generation as a ZIP archive.""" try: # Get generation to create filename generation = db.query(DBGeneration).filter_by(id=generation_id).first() if not generation: raise HTTPException(status_code=404, detail="Generation not found") # Export to ZIP zip_bytes = export_import.export_generation_to_zip(generation_id, db) # Create safe filename from text safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (' ', '-', '_')).strip() if not safe_text: safe_text = "generation" filename = f"generation-{safe_text}.voicebox.zip" # Return as streaming response return StreamingResponse( io.BytesIO(zip_bytes), media_type="application/zip", headers={ "Content-Disposition": f'attachment; filename="{filename}"' } ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/history/{generation_id}/export-audio") async def export_generation_audio( generation_id: str, db: Session = Depends(get_db), ): """Export only the audio file from a generation.""" generation = db.query(DBGeneration).filter_by(id=generation_id).first() if not generation: raise HTTPException(status_code=404, detail="Generation not found") audio_path = Path(generation.audio_path) if not audio_path.exists(): raise HTTPException(status_code=404, detail="Audio file not found") # Create safe filename from text safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (' ', '-', '_')).strip() if not safe_text: safe_text = "generation" filename = f"{safe_text}.wav" return FileResponse( audio_path, media_type="audio/wav", headers={ "Content-Disposition": f'attachment; filename="{filename}"' } ) # ============================================ # TRANSCRIPTION ENDPOINTS # ============================================ @app.post("/transcribe", response_model=models.TranscriptionResponse) async def transcribe_audio( file: UploadFile = File(...), language: Optional[str] = Form(None), ): """Transcribe audio file to text.""" # Save uploaded file to temporary location with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: content = await file.read() tmp.write(content) tmp_path = tmp.name try: # Get audio duration from .utils.audio import load_audio audio, sr = load_audio(tmp_path) duration = len(audio) / sr # Transcribe whisper_model = transcribe.get_whisper_model() text = await whisper_model.transcribe(tmp_path, language) return models.TranscriptionResponse( text=text, duration=duration, ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: # Clean up temp file Path(tmp_path).unlink(missing_ok=True) # ============================================ # FILE SERVING # ============================================ @app.get("/audio/{generation_id}") async def get_audio(generation_id: str, db: Session = Depends(get_db)): """Serve generated audio file.""" generation = await history.get_generation(generation_id, db) if not generation: raise HTTPException(status_code=404, detail="Generation not found") audio_path = Path(generation.audio_path) if not audio_path.exists(): raise HTTPException(status_code=404, detail="Audio file not found") return FileResponse( audio_path, media_type="audio/wav", filename=f"generation_{generation_id}.wav", ) @app.get("/samples/{sample_id}") async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)): """Serve profile sample audio file.""" from .database import ProfileSample as DBProfileSample sample = db.query(DBProfileSample).filter_by(id=sample_id).first() if not sample: raise HTTPException(status_code=404, detail="Sample not found") audio_path = Path(sample.audio_path) if not audio_path.exists(): raise HTTPException(status_code=404, detail="Audio file not found") return FileResponse( audio_path, media_type="audio/wav", filename=f"sample_{sample_id}.wav", ) # ============================================ # MODEL MANAGEMENT # ============================================ @app.post("/models/load") async def load_model(model_size: str = "1.7B"): """Manually load TTS model.""" try: tts_model = tts.get_tts_model() await tts_model.load_model_async(model_size) return {"message": f"Model {model_size} loaded successfully"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/models/unload") async def unload_model(): """Unload TTS model to free memory.""" try: tts.unload_tts_model() return {"message": "Model unloaded successfully"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/models/progress/{model_name}") async def get_model_progress(model_name: str): """Get model download progress via Server-Sent Events.""" from fastapi.responses import StreamingResponse progress_manager = get_progress_manager() async def event_generator(): """Generate SSE events for progress updates.""" async for event in progress_manager.subscribe(model_name): yield event return StreamingResponse( event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) @app.get("/models/status", response_model=models.ModelStatusListResponse) async def get_model_status(): """Get status of all available models.""" from huggingface_hub import hf_hub_download from pathlib import Path import os # Try to import scan_cache_dir (might not be available in older versions) try: from huggingface_hub import scan_cache_dir use_scan_cache = True except ImportError: use_scan_cache = False def check_tts_loaded(model_size: str): """Check if TTS model is loaded with specific size.""" try: tts_model = tts.get_tts_model() return tts_model.is_loaded() and tts_model.model_size == model_size except Exception: return False def check_whisper_loaded(model_size: str): """Check if Whisper model is loaded with specific size.""" try: whisper_model = transcribe.get_whisper_model() return whisper_model.is_loaded() and whisper_model.model_size == model_size except Exception: return False model_configs = [ { "model_name": "qwen-tts-1.7B", "display_name": "Qwen TTS 1.7B", "hf_repo_id": "Qwen/Qwen3-TTS-12Hz-1.7B-Base", "model_size": "1.7B", "check_loaded": lambda: check_tts_loaded("1.7B"), }, { "model_name": "qwen-tts-0.6B", "display_name": "Qwen TTS 0.6B", "hf_repo_id": "Qwen/Qwen3-TTS-12Hz-0.6B-Base", "model_size": "0.6B", "check_loaded": lambda: check_tts_loaded("0.6B"), }, { "model_name": "whisper-base", "display_name": "Whisper Base", "hf_repo_id": "openai/whisper-base", "model_size": "base", "check_loaded": lambda: check_whisper_loaded("base"), }, { "model_name": "whisper-small", "display_name": "Whisper Small", "hf_repo_id": "openai/whisper-small", "model_size": "small", "check_loaded": lambda: check_whisper_loaded("small"), }, { "model_name": "whisper-medium", "display_name": "Whisper Medium", "hf_repo_id": "openai/whisper-medium", "model_size": "medium", "check_loaded": lambda: check_whisper_loaded("medium"), }, { "model_name": "whisper-large", "display_name": "Whisper Large", "hf_repo_id": "openai/whisper-large", "model_size": "large", "check_loaded": lambda: check_whisper_loaded("large"), }, ] # Get HuggingFace cache info (if available) cache_info = None if use_scan_cache: try: cache_info = scan_cache_dir() except Exception: # Function failed, continue without it pass statuses = [] for config in model_configs: try: downloaded = False size_mb = None loaded = False # Method 1: Try using scan_cache_dir if available if cache_info: repo_id = config["hf_repo_id"] for repo in cache_info.repos: if repo.repo_id == repo_id: downloaded = True # Calculate size from cache info try: total_size = sum(revision.size_on_disk for revision in repo.revisions) size_mb = total_size / (1024 * 1024) except Exception: pass break # Method 2: Fallback to checking cache directory directly if not downloaded: try: cache_dir = os.path.expanduser("~/.cache/huggingface/hub") repo_cache = Path(cache_dir) / "models--" + config["hf_repo_id"].replace("/", "--") if repo_cache.exists(): # Check for model files (bin, safetensors, or other common model files) has_model_files = ( any(repo_cache.rglob("*.bin")) or any(repo_cache.rglob("*.safetensors")) or any(repo_cache.rglob("*.pt")) or any(repo_cache.rglob("*.pth")) or any(repo_cache.rglob("model.safetensors.index.json")) or any(repo_cache.rglob("pytorch_model.bin.index.json")) ) if has_model_files: downloaded = True # Calculate size try: total_size = sum(f.stat().st_size for f in repo_cache.rglob("*") if f.is_file()) size_mb = total_size / (1024 * 1024) except Exception: pass except Exception: pass # Method 3: Try to check if model can be loaded locally (last resort) if not downloaded: try: # Try to download with local_files_only=True to check if cached hf_hub_download( repo_id=config["hf_repo_id"], filename="config.json", # Try a common file local_files_only=True, ) downloaded = True except Exception: # File not found locally, model not downloaded pass # Check if loaded in memory try: loaded = config["check_loaded"]() except Exception: loaded = False statuses.append(models.ModelStatus( model_name=config["model_name"], display_name=config["display_name"], downloaded=downloaded, size_mb=size_mb, loaded=loaded, )) except Exception as e: # If check fails, try to at least check if loaded try: loaded = config["check_loaded"]() except Exception: loaded = False statuses.append(models.ModelStatus( model_name=config["model_name"], display_name=config["display_name"], downloaded=False, # Assume not downloaded if check failed size_mb=None, loaded=loaded, )) return models.ModelStatusListResponse(models=statuses) @app.post("/models/download") async def trigger_model_download(request: models.ModelDownloadRequest): """Trigger download of a specific model.""" import asyncio task_manager = get_task_manager() model_configs = { "qwen-tts-1.7B": { "model_size": "1.7B", "load_func": lambda: tts.get_tts_model().load_model("1.7B"), }, "qwen-tts-0.6B": { "model_size": "0.6B", "load_func": lambda: tts.get_tts_model().load_model("0.6B"), }, "whisper-base": { "model_size": "base", "load_func": lambda: transcribe.get_whisper_model().load_model("base"), }, "whisper-small": { "model_size": "small", "load_func": lambda: transcribe.get_whisper_model().load_model("small"), }, "whisper-medium": { "model_size": "medium", "load_func": lambda: transcribe.get_whisper_model().load_model("medium"), }, "whisper-large": { "model_size": "large", "load_func": lambda: transcribe.get_whisper_model().load_model("large"), }, } if request.model_name not in model_configs: raise HTTPException(status_code=400, detail=f"Unknown model: {request.model_name}") config = model_configs[request.model_name] try: # Start tracking download task_manager.start_download(request.model_name) # Trigger download by loading the model (which will download if not cached) # Run in background to avoid blocking await asyncio.to_thread(config["load_func"]) # Mark download as complete task_manager.complete_download(request.model_name) return {"message": f"Model {request.model_name} download started"} except Exception as e: # Mark download as failed task_manager.error_download(request.model_name, str(e)) raise HTTPException(status_code=500, detail=str(e)) @app.delete("/models/{model_name}") async def delete_model(model_name: str): """Delete a downloaded model from the HuggingFace cache.""" import shutil import os # Map model names to HuggingFace repo IDs model_configs = { "qwen-tts-1.7B": { "hf_repo_id": "Qwen/Qwen3-TTS-12Hz-1.7B-Base", "model_size": "1.7B", "model_type": "tts", }, "qwen-tts-0.6B": { "hf_repo_id": "Qwen/Qwen3-TTS-12Hz-0.6B-Base", "model_size": "0.6B", "model_type": "tts", }, "whisper-base": { "hf_repo_id": "openai/whisper-base", "model_size": "base", "model_type": "whisper", }, "whisper-small": { "hf_repo_id": "openai/whisper-small", "model_size": "small", "model_type": "whisper", }, "whisper-medium": { "hf_repo_id": "openai/whisper-medium", "model_size": "medium", "model_type": "whisper", }, "whisper-large": { "hf_repo_id": "openai/whisper-large", "model_size": "large", "model_type": "whisper", }, } if model_name not in model_configs: raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}") config = model_configs[model_name] hf_repo_id = config["hf_repo_id"] try: # Check if model is loaded and unload it first if config["model_type"] == "tts": tts_model = tts.get_tts_model() if tts_model.is_loaded() and tts_model.model_size == config["model_size"]: tts.unload_tts_model() elif config["model_type"] == "whisper": whisper_model = transcribe.get_whisper_model() if whisper_model.is_loaded() and whisper_model.model_size == config["model_size"]: transcribe.unload_whisper_model() # Find and delete the cache directory cache_dir = os.path.expanduser("~/.cache/huggingface/hub") repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--")) # Check if the cache directory exists if not repo_cache_dir.exists(): raise HTTPException(status_code=404, detail=f"Model {model_name} not found in cache") # Delete the entire cache directory for this model try: shutil.rmtree(repo_cache_dir) except OSError as e: raise HTTPException( status_code=500, detail=f"Failed to delete model cache directory: {str(e)}" ) return {"message": f"Model {model_name} deleted successfully"} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}") # ============================================ # TASK MANAGEMENT # ============================================ @app.get("/tasks/active", response_model=models.ActiveTasksResponse) async def get_active_tasks(): """Return all currently active downloads and generations.""" task_manager = get_task_manager() progress_manager = get_progress_manager() # Get active downloads from both task manager and progress manager # Task manager tracks which downloads are active # Progress manager has the actual progress data active_downloads = [] task_manager_downloads = task_manager.get_active_downloads() progress_active = progress_manager.get_all_active() # Combine data from both sources download_map = {task.model_name: task for task in task_manager_downloads} progress_map = {p["model_name"]: p for p in progress_active} # Create unified list all_model_names = set(download_map.keys()) | set(progress_map.keys()) for model_name in all_model_names: task = download_map.get(model_name) progress = progress_map.get(model_name) if task: active_downloads.append(models.ActiveDownloadTask( model_name=model_name, status=task.status, started_at=task.started_at, )) elif progress: # Progress exists but no task - create from progress data timestamp_str = progress.get("timestamp") if timestamp_str: try: started_at = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) except (ValueError, AttributeError): started_at = datetime.utcnow() else: started_at = datetime.utcnow() active_downloads.append(models.ActiveDownloadTask( model_name=model_name, status=progress.get("status", "downloading"), started_at=started_at, )) # Get active generations active_generations = [] for gen_task in task_manager.get_active_generations(): active_generations.append(models.ActiveGenerationTask( task_id=gen_task.task_id, profile_id=gen_task.profile_id, text_preview=gen_task.text_preview, started_at=gen_task.started_at, )) return models.ActiveTasksResponse( downloads=active_downloads, generations=active_generations, ) # ============================================ # STARTUP & SHUTDOWN # ============================================ @app.on_event("startup") async def startup_event(): """Run on application startup.""" print("voicebox API starting up...") database.init_db() print(f"Database initialized at {database._db_path}") print(f"GPU available: {torch.cuda.is_available()}") @app.on_event("shutdown") async def shutdown_event(): """Run on application shutdown.""" print("voicebox API shutting down...") # Unload models to free memory tts.unload_tts_model() transcribe.unload_whisper_model() # ============================================ # MAIN # ============================================ if __name__ == "__main__": parser = argparse.ArgumentParser(description="voicebox backend server") parser.add_argument( "--host", type=str, default="127.0.0.1", help="Host to bind to (use 0.0.0.0 for remote access)", ) parser.add_argument( "--port", type=int, default=8000, help="Port to bind to", ) parser.add_argument( "--data-dir", type=str, default=None, help="Data directory for database, profiles, and generated audio", ) args = parser.parse_args() # Set data directory if provided if args.data_dir: config.set_data_dir(args.data_dir) # Initialize database after data directory is set database.init_db() uvicorn.run( "backend.main:app", host=args.host, port=args.port, reload=False, # Disable reload in production )