mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 21:00:42 -07:00
- Updated CONTRIBUTING.md to include instructions for building with a local Qwen3-TTS development version, facilitating easier testing and development. - Refactored FloatingGenerateBox component to streamline the rendering of text and instruct fields, improving code readability and maintainability. - Added functionality to handle auto-resizing of text areas based on content changes, enhancing user experience. - Improved event handling for keyboard interactions in StoryTrackEditor, allowing for play/pause functionality with the spacebar. - Introduced a MiniSamplePlayer component in SampleList for better audio playback control, including play, pause, and seek features. - Implemented sample update functionality in the backend, allowing users to edit reference text for audio samples, with appropriate error handling and user feedback.
1555 lines
51 KiB
Python
1555 lines
51 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 asyncio
|
|
import uvicorn
|
|
import argparse
|
|
import torch
|
|
import tempfile
|
|
import io
|
|
from pathlib import Path
|
|
import uuid
|
|
import asyncio
|
|
import signal
|
|
import os
|
|
|
|
from . import database, models, profiles, history, tts, transcribe, config, export_import, channels, stories, __version__
|
|
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=__version__,
|
|
)
|
|
|
|
# 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": __version__}
|
|
|
|
|
|
@app.post("/shutdown")
|
|
async def shutdown():
|
|
"""Gracefully shutdown the server."""
|
|
async def shutdown_async():
|
|
await asyncio.sleep(0.1) # Give response time to send
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
|
|
asyncio.create_task(shutdown_async())
|
|
return {"message": "Shutting down..."}
|
|
|
|
|
|
@app.get("/health", response_model=models.HealthResponse)
|
|
async def health():
|
|
"""Health check endpoint."""
|
|
from huggingface_hub import hf_hub_download, constants as hf_constants
|
|
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
|
|
|
|
gpu_type = None
|
|
if has_cuda:
|
|
gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})"
|
|
elif has_mps:
|
|
gpu_type = "MPS (Apple Silicon)"
|
|
|
|
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 (using HuggingFace's OS-specific cache location)
|
|
cache_dir = hf_constants.HF_HUB_CACHE
|
|
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,
|
|
gpu_type=gpu_type,
|
|
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.put("/profiles/samples/{sample_id}", response_model=models.ProfileSampleResponse)
|
|
async def update_profile_sample(
|
|
sample_id: str,
|
|
data: models.ProfileSampleUpdate,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update a profile sample's reference text."""
|
|
sample = await profiles.update_profile_sample(sample_id, data.reference_text, db)
|
|
if not sample:
|
|
raise HTTPException(status_code=404, detail="Sample not found")
|
|
return sample
|
|
|
|
|
|
@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"
|
|
|
|
# Check if model needs to be downloaded first
|
|
model_path = tts_model._get_model_path(model_size)
|
|
if model_path.startswith("Qwen/"):
|
|
# Model not cached - check if it exists remotely or needs download
|
|
from huggingface_hub import constants as hf_constants
|
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
|
|
if not repo_cache.exists():
|
|
# Start download in background
|
|
model_name = f"qwen-tts-{model_size}"
|
|
|
|
async def download_model_background():
|
|
try:
|
|
await tts_model.load_model_async(model_size)
|
|
except Exception as e:
|
|
task_manager.error_download(model_name, str(e))
|
|
|
|
task_manager.start_download(model_name)
|
|
asyncio.create_task(download_model_background())
|
|
|
|
# Return 202 Accepted with download info
|
|
raise HTTPException(
|
|
status_code=202,
|
|
detail={
|
|
"message": f"Model {model_size} is being downloaded. Please wait and try again.",
|
|
"model_name": model_name,
|
|
"downloading": True
|
|
}
|
|
)
|
|
|
|
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()
|
|
|
|
# Check if Whisper model is downloaded (uses default size "base")
|
|
model_size = whisper_model.model_size
|
|
model_name = f"openai/whisper-{model_size}"
|
|
|
|
# Check if model is cached
|
|
from huggingface_hub import constants as hf_constants
|
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
|
|
if not repo_cache.exists():
|
|
# Start download in background
|
|
progress_model_name = f"whisper-{model_size}"
|
|
|
|
async def download_whisper_background():
|
|
try:
|
|
await whisper_model.load_model_async(model_size)
|
|
except Exception as e:
|
|
get_task_manager().error_download(progress_model_name, str(e))
|
|
|
|
get_task_manager().start_download(progress_model_name)
|
|
asyncio.create_task(download_whisper_background())
|
|
|
|
# Return 202 Accepted
|
|
raise HTTPException(
|
|
status_code=202,
|
|
detail={
|
|
"message": f"Whisper model {model_size} is being downloaded. Please wait and try again.",
|
|
"model_name": progress_model_name,
|
|
"downloading": True
|
|
}
|
|
)
|
|
|
|
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/{item_id}")
|
|
async def remove_story_item(
|
|
story_id: str,
|
|
item_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Remove a story item from a story."""
|
|
success = await stories.remove_item_from_story(story_id, item_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/{item_id}/move", response_model=models.StoryItemDetail)
|
|
async def move_story_item(
|
|
story_id: str,
|
|
item_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, item_id, data, db)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="Story item not found")
|
|
return item
|
|
|
|
|
|
@app.put("/stories/{story_id}/items/{item_id}/trim", response_model=models.StoryItemDetail)
|
|
async def trim_story_item(
|
|
story_id: str,
|
|
item_id: str,
|
|
data: models.StoryItemTrim,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Trim a story item (update trim_start_ms and trim_end_ms)."""
|
|
item = await stories.trim_story_item(story_id, item_id, data, db)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="Story item not found or invalid trim values")
|
|
return item
|
|
|
|
|
|
@app.post("/stories/{story_id}/items/{item_id}/split", response_model=List[models.StoryItemDetail])
|
|
async def split_story_item(
|
|
story_id: str,
|
|
item_id: str,
|
|
data: models.StoryItemSplit,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Split a story item at a given time, creating two clips."""
|
|
items = await stories.split_story_item(story_id, item_id, data, db)
|
|
if items is None:
|
|
raise HTTPException(status_code=404, detail="Story item not found or invalid split point")
|
|
return items
|
|
|
|
|
|
@app.post("/stories/{story_id}/items/{item_id}/duplicate", response_model=models.StoryItemDetail)
|
|
async def duplicate_story_item(
|
|
story_id: str,
|
|
item_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Duplicate a story item, creating a copy with all properties."""
|
|
item = await stories.duplicate_story_item(story_id, item_id, 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, constants as hf_constants
|
|
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 (using HuggingFace's OS-specific cache location)
|
|
if not downloaded:
|
|
try:
|
|
cache_dir = hf_constants.HF_HUB_CACHE
|
|
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]
|
|
|
|
async def download_in_background():
|
|
"""Download model in background without blocking the HTTP request."""
|
|
try:
|
|
await asyncio.to_thread(config["load_func"])
|
|
task_manager.complete_download(request.model_name)
|
|
except Exception as e:
|
|
task_manager.error_download(request.model_name, str(e))
|
|
|
|
# Start tracking download
|
|
task_manager.start_download(request.model_name)
|
|
|
|
# Start download in background task (don't await)
|
|
asyncio.create_task(download_in_background())
|
|
|
|
# Return immediately - frontend should poll progress endpoint
|
|
return {"message": f"Model {request.model_name} download started"}
|
|
|
|
|
|
@app.delete("/models/{model_name}")
|
|
async def delete_model(model_name: str):
|
|
"""Delete a downloaded model from the HuggingFace cache."""
|
|
import shutil
|
|
import os
|
|
from huggingface_hub import constants as hf_constants
|
|
|
|
# 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 (using HuggingFace's OS-specific cache location)
|
|
cache_dir = hf_constants.HF_HUB_CACHE
|
|
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()}")
|
|
|
|
# Ensure HuggingFace cache directory exists
|
|
try:
|
|
from huggingface_hub import constants as hf_constants
|
|
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
print(f"HuggingFace cache directory: {cache_dir}")
|
|
except Exception as e:
|
|
print(f"Warning: Could not create HuggingFace cache directory: {e}")
|
|
print("Model downloads may fail. Please ensure the directory exists and has write permissions.")
|
|
|
|
|
|
@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
|
|
)
|