mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 12:50:42 -07:00
1407 lines
46 KiB
Python
1407 lines
46 KiB
Python
"""
|
|
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, channels, stories
|
|
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.6"}
|
|
|
|
|
|
@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()
|
|
|
|
# Check for GPU availability (CUDA or MPS)
|
|
has_cuda = torch.cuda.is_available()
|
|
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
|
gpu_available = has_cuda or has_mps
|
|
|
|
vram_used = None
|
|
if has_cuda:
|
|
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))
|
|
|
|
|
|
# ============================================
|
|
# AUDIO CHANNEL ENDPOINTS
|
|
# ============================================
|
|
|
|
@app.get("/channels", response_model=List[models.AudioChannelResponse])
|
|
async def list_channels(db: Session = Depends(get_db)):
|
|
"""List all audio channels."""
|
|
return await channels.list_channels(db)
|
|
|
|
|
|
@app.post("/channels", response_model=models.AudioChannelResponse)
|
|
async def create_channel(
|
|
data: models.AudioChannelCreate,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Create a new audio channel."""
|
|
try:
|
|
return await channels.create_channel(data, db)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@app.get("/channels/{channel_id}", response_model=models.AudioChannelResponse)
|
|
async def get_channel(
|
|
channel_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get an audio channel by ID."""
|
|
channel = await channels.get_channel(channel_id, db)
|
|
if not channel:
|
|
raise HTTPException(status_code=404, detail="Channel not found")
|
|
return channel
|
|
|
|
|
|
@app.put("/channels/{channel_id}", response_model=models.AudioChannelResponse)
|
|
async def update_channel(
|
|
channel_id: str,
|
|
data: models.AudioChannelUpdate,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update an audio channel."""
|
|
try:
|
|
channel = await channels.update_channel(channel_id, data, db)
|
|
if not channel:
|
|
raise HTTPException(status_code=404, detail="Channel not found")
|
|
return channel
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@app.delete("/channels/{channel_id}")
|
|
async def delete_channel(
|
|
channel_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Delete an audio channel."""
|
|
try:
|
|
success = await channels.delete_channel(channel_id, db)
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="Channel not found")
|
|
return {"message": "Channel deleted successfully"}
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@app.get("/channels/{channel_id}/voices")
|
|
async def get_channel_voices(
|
|
channel_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get list of profile IDs assigned to a channel."""
|
|
try:
|
|
profile_ids = await channels.get_channel_voices(channel_id, db)
|
|
return {"profile_ids": profile_ids}
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@app.put("/channels/{channel_id}/voices")
|
|
async def set_channel_voices(
|
|
channel_id: str,
|
|
data: models.ChannelVoiceAssignment,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Set which voices are assigned to a channel."""
|
|
try:
|
|
await channels.set_channel_voices(channel_id, data, db)
|
|
return {"message": "Channel voices updated successfully"}
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@app.get("/profiles/{profile_id}/channels")
|
|
async def get_profile_channels(
|
|
profile_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get list of channel IDs assigned to a profile."""
|
|
try:
|
|
channel_ids = await channels.get_profile_channels(profile_id, db)
|
|
return {"channel_ids": channel_ids}
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@app.put("/profiles/{profile_id}/channels")
|
|
async def set_profile_channels(
|
|
profile_id: str,
|
|
data: models.ProfileChannelAssignment,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Set which channels a profile is assigned to."""
|
|
try:
|
|
await channels.set_profile_channels(profile_id, data, db)
|
|
return {"message": "Profile channels updated successfully"}
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, 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)
|
|
|
|
|
|
# ============================================
|
|
# STORY ENDPOINTS
|
|
# ============================================
|
|
|
|
@app.get("/stories", response_model=List[models.StoryResponse])
|
|
async def list_stories(db: Session = Depends(get_db)):
|
|
"""List all stories."""
|
|
return await stories.list_stories(db)
|
|
|
|
|
|
@app.post("/stories", response_model=models.StoryResponse)
|
|
async def create_story(
|
|
data: models.StoryCreate,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Create a new story."""
|
|
try:
|
|
return await stories.create_story(data, db)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@app.get("/stories/{story_id}", response_model=models.StoryDetailResponse)
|
|
async def get_story(
|
|
story_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get a story with all its items."""
|
|
story = await stories.get_story(story_id, db)
|
|
if not story:
|
|
raise HTTPException(status_code=404, detail="Story not found")
|
|
return story
|
|
|
|
|
|
@app.put("/stories/{story_id}", response_model=models.StoryResponse)
|
|
async def update_story(
|
|
story_id: str,
|
|
data: models.StoryCreate,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update a story."""
|
|
story = await stories.update_story(story_id, data, db)
|
|
if not story:
|
|
raise HTTPException(status_code=404, detail="Story not found")
|
|
return story
|
|
|
|
|
|
@app.delete("/stories/{story_id}")
|
|
async def delete_story(
|
|
story_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Delete a story."""
|
|
success = await stories.delete_story(story_id, db)
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="Story not found")
|
|
return {"message": "Story deleted successfully"}
|
|
|
|
|
|
@app.post("/stories/{story_id}/items", response_model=models.StoryItemDetail)
|
|
async def add_story_item(
|
|
story_id: str,
|
|
data: models.StoryItemCreate,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Add a generation to a story."""
|
|
item = await stories.add_item_to_story(story_id, data, db)
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Story or generation not found")
|
|
return item
|
|
|
|
|
|
@app.delete("/stories/{story_id}/items/{generation_id}")
|
|
async def remove_story_item(
|
|
story_id: str,
|
|
generation_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Remove a generation from a story."""
|
|
success = await stories.remove_item_from_story(story_id, generation_id, db)
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="Story item not found")
|
|
return {"message": "Item removed successfully"}
|
|
|
|
|
|
@app.put("/stories/{story_id}/items/times")
|
|
async def update_story_item_times(
|
|
story_id: str,
|
|
data: models.StoryItemBatchUpdate,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update story item timecodes."""
|
|
success = await stories.update_story_item_times(story_id, data, db)
|
|
if not success:
|
|
raise HTTPException(status_code=400, detail="Invalid timecode update request")
|
|
return {"message": "Item timecodes updated successfully"}
|
|
|
|
|
|
@app.put("/stories/{story_id}/items/reorder", response_model=List[models.StoryItemDetail])
|
|
async def reorder_story_items(
|
|
story_id: str,
|
|
data: models.StoryItemReorder,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Reorder story items and recalculate timecodes."""
|
|
items = await stories.reorder_story_items(story_id, data.generation_ids, db)
|
|
if items is None:
|
|
raise HTTPException(status_code=400, detail="Invalid reorder request - ensure all generation IDs belong to this story")
|
|
return items
|
|
|
|
|
|
@app.put("/stories/{story_id}/items/{generation_id}/move", response_model=models.StoryItemDetail)
|
|
async def move_story_item(
|
|
story_id: str,
|
|
generation_id: str,
|
|
data: models.StoryItemMove,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Move a story item (update position and/or track)."""
|
|
item = await stories.move_story_item(story_id, generation_id, data, db)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="Story item not found")
|
|
return item
|
|
|
|
|
|
@app.get("/stories/{story_id}/export-audio")
|
|
async def export_story_audio(
|
|
story_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Export story as single mixed audio file with timecode-based mixing."""
|
|
try:
|
|
# Get story to create filename
|
|
story = db.query(database.Story).filter_by(id=story_id).first()
|
|
if not story:
|
|
raise HTTPException(status_code=404, detail="Story not found")
|
|
|
|
# Export audio
|
|
audio_bytes = await stories.export_story_audio(story_id, db)
|
|
if not audio_bytes:
|
|
raise HTTPException(status_code=400, detail="Story has no audio items")
|
|
|
|
# Create safe filename
|
|
safe_name = "".join(c for c in story.name if c.isalnum() or c in (' ', '-', '_')).strip()
|
|
if not safe_name:
|
|
safe_name = "story"
|
|
filename = f"{safe_name}.wav"
|
|
|
|
# Return as streaming response
|
|
return StreamingResponse(
|
|
io.BytesIO(audio_bytes),
|
|
media_type="audio/wav",
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="{filename}"'
|
|
}
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# ============================================
|
|
# 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
|
|
# ============================================
|
|
|
|
def _get_gpu_status() -> str:
|
|
"""Get GPU availability status."""
|
|
if torch.cuda.is_available():
|
|
return f"CUDA ({torch.cuda.get_device_name(0)})"
|
|
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
|
return "MPS (Apple Silicon)"
|
|
return "None (CPU only)"
|
|
|
|
|
|
@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: {_get_gpu_status()}")
|
|
|
|
|
|
@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
|
|
)
|