From b7781951df1ceb1021a522ecbddcd4f30455d860 Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 16 Mar 2026 01:46:19 -0700 Subject: [PATCH] comment cleanup --- backend/backends/__init__.py | 107 +++- backend/backends/base.py | 27 +- backend/main.py | 594 +++++++++++----------- backend/models.py | 80 ++- backend/profiles.py | 139 ++--- backend/services/generation.py | 17 +- backend/stories.py | 290 ++++++----- backend/tests/test_generation_download.py | 46 +- backend/utils/chunked_tts.py | 35 +- backend/utils/effects.py | 97 ++-- 10 files changed, 738 insertions(+), 694 deletions(-) diff --git a/backend/backends/__init__.py b/backend/backends/__init__.py index c10f2241..3ebbb985 100644 --- a/backend/backends/__init__.py +++ b/backend/backends/__init__.py @@ -14,9 +14,16 @@ import numpy as np from ..platform_detect import get_backend_type LANGUAGE_CODE_TO_NAME = { - "zh": "chinese", "en": "english", "ja": "japanese", "ko": "korean", - "de": "german", "fr": "french", "ru": "russian", "pt": "portuguese", - "es": "spanish", "it": "italian", + "zh": "chinese", + "en": "english", + "ja": "japanese", + "ko": "korean", + "de": "german", + "fr": "french", + "ru": "russian", + "pt": "portuguese", + "es": "spanish", + "it": "italian", } WHISPER_HF_REPOS = { @@ -31,10 +38,11 @@ WHISPER_HF_REPOS = { @dataclass class ModelConfig: """Declarative config for a downloadable model variant.""" - model_name: str # e.g. "luxtts", "chatterbox-tts" - display_name: str # e.g. "LuxTTS (Fast, CPU-friendly)" - engine: str # e.g. "luxtts", "chatterbox" - hf_repo_id: str # e.g. "YatharthS/LuxTTS" + + model_name: str # e.g. "luxtts", "chatterbox-tts" + display_name: str # e.g. "LuxTTS (Fast, CPU-friendly)" + engine: str # e.g. "luxtts", "chatterbox" + hf_repo_id: str # e.g. "YatharthS/LuxTTS" model_size: str = "default" size_mb: int = 0 needs_trim: bool = False @@ -160,10 +168,6 @@ TTS_ENGINES = { } -# --------------------------------------------------------------------------- -# Model config registry -# --------------------------------------------------------------------------- - def _get_qwen_model_configs() -> list[ModelConfig]: """Return Qwen model configs with backend-aware HF repo IDs.""" backend_type = get_backend_type() @@ -220,9 +224,29 @@ def _get_non_qwen_tts_configs() -> list[ModelConfig]: size_mb=3200, needs_trim=True, languages=[ - "zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it", - "he", "ar", "da", "el", "fi", "hi", "ms", "nl", "no", "pl", - "sv", "sw", "tr", + "zh", + "en", + "ja", + "ko", + "de", + "fr", + "ru", + "pt", + "es", + "it", + "he", + "ar", + "da", + "el", + "fi", + "hi", + "ms", + "nl", + "no", + "pl", + "sv", + "sw", + "tr", ], ), ModelConfig( @@ -240,11 +264,41 @@ def _get_non_qwen_tts_configs() -> list[ModelConfig]: def _get_whisper_configs() -> list[ModelConfig]: """Return Whisper STT model configs.""" return [ - ModelConfig(model_name="whisper-base", display_name="Whisper Base", engine="whisper", hf_repo_id="openai/whisper-base", model_size="base"), - ModelConfig(model_name="whisper-small", display_name="Whisper Small", engine="whisper", hf_repo_id="openai/whisper-small", model_size="small"), - ModelConfig(model_name="whisper-medium", display_name="Whisper Medium", engine="whisper", hf_repo_id="openai/whisper-medium", model_size="medium"), - ModelConfig(model_name="whisper-large", display_name="Whisper Large", engine="whisper", hf_repo_id="openai/whisper-large-v3", model_size="large"), - ModelConfig(model_name="whisper-turbo", display_name="Whisper Turbo", engine="whisper", hf_repo_id="openai/whisper-large-v3-turbo", model_size="turbo"), + ModelConfig( + model_name="whisper-base", + display_name="Whisper Base", + engine="whisper", + hf_repo_id="openai/whisper-base", + model_size="base", + ), + ModelConfig( + model_name="whisper-small", + display_name="Whisper Small", + engine="whisper", + hf_repo_id="openai/whisper-small", + model_size="small", + ), + ModelConfig( + model_name="whisper-medium", + display_name="Whisper Medium", + engine="whisper", + hf_repo_id="openai/whisper-medium", + model_size="medium", + ), + ModelConfig( + model_name="whisper-large", + display_name="Whisper Large", + engine="whisper", + hf_repo_id="openai/whisper-large-v3", + model_size="large", + ), + ModelConfig( + model_name="whisper-turbo", + display_name="Whisper Turbo", + engine="whisper", + hf_repo_id="openai/whisper-large-v3-turbo", + model_size="turbo", + ), ] @@ -260,6 +314,7 @@ def get_tts_model_configs() -> list[ModelConfig]: # Lookup helpers โ€” these replace the if/elif chains in main.py + def get_model_config(model_name: str) -> Optional[ModelConfig]: """Look up a model config by model_name.""" for cfg in get_all_model_configs(): @@ -294,6 +349,7 @@ async def load_engine_model(engine: str, model_size: str = "default") -> None: async def ensure_model_cached_or_raise(engine: str, model_size: str = "default") -> None: """Check if a model is cached, raise HTTPException if not. Used by streaming endpoint.""" from fastapi import HTTPException + backend = get_tts_backend_for_engine(engine) cfg = None for c in get_tts_model_configs(): @@ -352,7 +408,7 @@ def check_model_loaded(config: ModelConfig) -> bool: try: if config.engine == "whisper": whisper_model = transcribe.get_whisper_model() - return whisper_model.is_loaded() and getattr(whisper_model, 'model_size', None) == config.model_size + return whisper_model.is_loaded() and getattr(whisper_model, "model_size", None) == config.model_size if config.engine == "qwen": tts_model = tts.get_tts_model() @@ -379,10 +435,6 @@ def get_model_load_func(config: ModelConfig): return lambda: get_tts_backend_for_engine(config.engine).load_model() -# --------------------------------------------------------------------------- -# Backend factory -# --------------------------------------------------------------------------- - def get_tts_backend() -> TTSBackend: """ Get or create the default (Qwen) TTS backend instance based on platform. @@ -419,18 +471,23 @@ def get_tts_backend_for_engine(engine: str) -> TTSBackend: backend_type = get_backend_type() if backend_type == "mlx": from .mlx_backend import MLXTTSBackend + backend = MLXTTSBackend() else: from .pytorch_backend import PyTorchTTSBackend + backend = PyTorchTTSBackend() elif engine == "luxtts": from .luxtts_backend import LuxTTSBackend + backend = LuxTTSBackend() elif engine == "chatterbox": from .chatterbox_backend import ChatterboxTTSBackend + backend = ChatterboxTTSBackend() elif engine == "chatterbox_turbo": from .chatterbox_turbo_backend import ChatterboxTurboTTSBackend + backend = ChatterboxTurboTTSBackend() else: raise ValueError(f"Unknown TTS engine: {engine}. Supported: {list(TTS_ENGINES.keys())}") @@ -453,9 +510,11 @@ def get_stt_backend() -> STTBackend: if backend_type == "mlx": from .mlx_backend import MLXSTTBackend + _stt_backend = MLXSTTBackend() else: from .pytorch_backend import PyTorchSTTBackend + _stt_backend = PyTorchSTTBackend() return _stt_backend diff --git a/backend/backends/base.py b/backend/backends/base.py index f839df35..9a3049a0 100644 --- a/backend/backends/base.py +++ b/backend/backends/base.py @@ -21,10 +21,6 @@ from ..utils.tasks import get_task_manager logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# HuggingFace cache checking -# --------------------------------------------------------------------------- - def is_model_cached( hf_repo: str, *, @@ -46,9 +42,7 @@ def is_model_cached( try: from huggingface_hub import constants as hf_constants - repo_cache = Path(hf_constants.HF_HUB_CACHE) / ( - "models--" + hf_repo.replace("/", "--") - ) + repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + hf_repo.replace("/", "--")) if not repo_cache.exists(): return False @@ -83,10 +77,6 @@ def is_model_cached( return False -# --------------------------------------------------------------------------- -# Device detection -# --------------------------------------------------------------------------- - def get_torch_device( *, allow_xpu: bool = False, @@ -114,6 +104,7 @@ def get_torch_device( if allow_xpu: try: import intel_extension_for_pytorch # noqa: F401 + if hasattr(torch, "xpu") and torch.xpu.is_available(): return "xpu" except ImportError: @@ -122,6 +113,7 @@ def get_torch_device( if allow_directml: try: import torch_directml + if torch_directml.device_count() > 0: return torch_directml.device(0) except ImportError: @@ -134,10 +126,6 @@ def get_torch_device( return "cpu" -# --------------------------------------------------------------------------- -# Voice prompt combination -# --------------------------------------------------------------------------- - async def combine_voice_prompts( audio_paths: List[str], reference_texts: List[str], @@ -169,10 +157,6 @@ async def combine_voice_prompts( return mixed, combined_text -# --------------------------------------------------------------------------- -# Model loading progress tracking -# --------------------------------------------------------------------------- - @contextmanager def model_load_progress( model_name: str, @@ -237,10 +221,6 @@ def model_load_progress( tracker_context.__exit__(None, None, None) -# --------------------------------------------------------------------------- -# Chatterbox f32 dtype patches -# --------------------------------------------------------------------------- - def patch_chatterbox_f32(model) -> None: """ Patch float64 -> float32 dtype mismatches in upstream chatterbox. @@ -261,6 +241,7 @@ def patch_chatterbox_f32(model) -> None: def _f32_log_mel(self_tokzr, audio, padding=0): import torch as _torch + if _torch.is_tensor(audio): audio = audio.float() return _orig_log_mel(self_tokzr, audio, padding) diff --git a/backend/main.py b/backend/main.py index 71cd240d..7774a922 100644 --- a/backend/main.py +++ b/backend/main.py @@ -41,17 +41,24 @@ def _safe_content_disposition(disposition_type: str, filename: str) -> str: Uses RFC 5987 ``filename*`` parameter so that browsers can decode UTF-8 filenames while the ``filename`` fallback stays ASCII-only. """ - ascii_name = "".join( - c for c in filename if c.isascii() and (c.isalnum() or c in " -_.") - ).strip() or "download" + ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " -_.")).strip() or "download" utf8_name = quote(filename, safe="") - return ( - f'{disposition_type}; filename="{ascii_name}"; ' - f"filename*=UTF-8''{utf8_name}" - ) + return f"{disposition_type}; filename=\"{ascii_name}\"; filename*=UTF-8''{utf8_name}" -from . import database, models, profiles, history, tts, transcribe, config, export_import, channels, stories, __version__ +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 .profiles import _profile_to_response from .utils.progress import get_progress_manager @@ -72,13 +79,13 @@ app = FastAPI( # Set VOICEBOX_CORS_ORIGINS env var to a comma-separated list of origins # to allow additional origins (e.g. for remote server mode). _default_origins = [ - "http://localhost:5173", # Vite dev server + "http://localhost:5173", # Vite dev server "http://127.0.0.1:5173", "http://localhost:17493", "http://127.0.0.1:17493", - "tauri://localhost", # Tauri webview (macOS) - "https://tauri.localhost", # Tauri webview (Windows/Linux) - "http://tauri.localhost", # Tauri webview (Windows, some builds) + "tauri://localhost", # Tauri webview (macOS) + "https://tauri.localhost", # Tauri webview (Windows/Linux) + "http://tauri.localhost", # Tauri webview (Windows, some builds) ] _env_origins = os.environ.get("VOICEBOX_CORS_ORIGINS", "") _cors_origins = _default_origins + [o.strip() for o in _env_origins.split(",") if o.strip()] @@ -92,10 +99,6 @@ app.add_middleware( ) -# ============================================ -# ROOT & HEALTH ENDPOINTS -# ============================================ - @app.get("/") async def root(): """Root endpoint.""" @@ -105,6 +108,7 @@ async def root(): @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) @@ -117,6 +121,7 @@ async def shutdown(): async def watchdog_disable(): """Disable the parent process watchdog so the server keeps running.""" from backend.server import disable_watchdog + disable_watchdog() return {"message": "Watchdog disabled"} @@ -133,14 +138,15 @@ async def health(): # Check for GPU availability (CUDA, MPS, Intel Arc XPU, or DirectML) has_cuda = torch.cuda.is_available() - has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available() + has_mps = hasattr(torch.backends, "mps") and torch.backends.mps.is_available() # Intel Arc / Intel Xe via intel-extension-for-pytorch (IPEX) has_xpu = False xpu_name = None try: import intel_extension_for_pytorch as ipex # noqa: F401 - if hasattr(torch, 'xpu') and torch.xpu.is_available(): + + if hasattr(torch, "xpu") and torch.xpu.is_available(): has_xpu = True try: xpu_name = torch.xpu.get_device_name(0) @@ -154,6 +160,7 @@ async def health(): directml_name = None try: import torch_directml + if torch_directml.device_count() > 0: has_directml = True try: @@ -180,7 +187,7 @@ async def health(): 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 @@ -190,26 +197,28 @@ async def health(): 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) + 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) + 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 from .backends import get_model_config + default_config = get_model_config("qwen-tts-1.7B") default_model_id = default_config.hf_repo_id if default_config else "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: @@ -221,16 +230,16 @@ async def health(): 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")) or - any(repo_cache.rglob("*.npz")) # MLX models may use npz + 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("*.npz")) # MLX models may use npz ) model_downloaded = has_model_files except Exception: pass - + return models.HealthResponse( status="healthy", model_loaded=model_loaded, @@ -313,10 +322,6 @@ async def filesystem_health(): ) -# ============================================ -# VOICE PROFILE ENDPOINTS -# ============================================ - @app.post("/profiles", response_model=models.VoiceProfileResponse) async def create_profile( data: models.VoiceProfileCreate, @@ -346,16 +351,15 @@ async def import_profile( """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" + 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 @@ -415,9 +419,9 @@ async def add_profile_sample( """Add a sample to a voice profile.""" # Preserve the uploaded file's extension so librosa can detect format correctly. # Defaulting to .wav was causing soundfile to reject MP3/WebM content as invalid WAV. - _allowed_audio_exts = {'.wav', '.mp3', '.m4a', '.ogg', '.flac', '.aac', '.webm', '.opus'} - _uploaded_ext = Path(file.filename or '').suffix.lower() - file_suffix = _uploaded_ext if _uploaded_ext in _allowed_audio_exts else '.wav' + _allowed_audio_exts = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".aac", ".webm", ".opus"} + _uploaded_ext = Path(file.filename or "").suffix.lower() + file_suffix = _uploaded_ext if _uploaded_ext in _allowed_audio_exts else ".wav" with tempfile.NamedTemporaryFile(suffix=file_suffix, delete=False) as tmp: content = await file.read() @@ -541,23 +545,21 @@ async def export_profile( 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() + 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": _safe_content_disposition("attachment", filename) - } + headers={"Content-Disposition": _safe_content_disposition("attachment", filename)}, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -565,10 +567,6 @@ async def export_profile( 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.""" @@ -684,17 +682,13 @@ async def set_profile_channels( 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. - + Creates a history entry immediately with status='generating' and kicks off TTS in the background. The frontend can poll or use SSE to detect completion. """ @@ -707,6 +701,7 @@ async def generate_speech( raise HTTPException(status_code=404, detail="Profile not found") from .backends import engine_has_model_sizes + engine = data.engine or "qwen" model_size = data.model_size or "1.7B" @@ -740,6 +735,7 @@ async def generate_speech( else: # Check profile default import json as _json + profile_obj = db.query(DBVoiceProfile).filter_by(id=data.profile_id).first() if profile_obj and profile_obj.effects_chain: try: @@ -748,21 +744,23 @@ async def generate_speech( pass # Kick off TTS in background - enqueue_generation(run_generation( - generation_id=generation_id, - profile_id=data.profile_id, - text=data.text, - language=data.language, - engine=engine, - model_size=model_size, - seed=data.seed, - normalize=data.normalize, - effects_chain=effects_chain_config, - instruct=data.instruct, - mode="generate", - max_chunk_chars=data.max_chunk_chars, - crossfade_ms=data.crossfade_ms, - )) + enqueue_generation( + run_generation( + generation_id=generation_id, + profile_id=data.profile_id, + text=data.text, + language=data.language, + engine=engine, + model_size=model_size, + seed=data.seed, + normalize=data.normalize, + effects_chain=effects_chain_config, + instruct=data.instruct, + mode="generate", + max_chunk_chars=data.max_chunk_chars, + crossfade_ms=data.crossfade_ms, + ) + ) return generation @@ -792,17 +790,19 @@ async def retry_generation(generation_id: str, db: Session = Depends(get_db)): text=gen.text, ) - enqueue_generation(run_generation( - generation_id=generation_id, - profile_id=gen.profile_id, - text=gen.text, - language=gen.language, - engine=gen.engine or "qwen", - model_size=gen.model_size or "1.7B", - seed=gen.seed, - instruct=gen.instruct, - mode="retry", - )) + enqueue_generation( + run_generation( + generation_id=generation_id, + profile_id=gen.profile_id, + text=gen.text, + language=gen.language, + engine=gen.engine or "qwen", + model_size=gen.model_size or "1.7B", + seed=gen.seed, + instruct=gen.instruct, + mode="retry", + ) + ) return models.GenerationResponse.model_validate(gen) @@ -834,18 +834,20 @@ async def regenerate_generation(generation_id: str, db: Session = Depends(get_db version_id = str(uuid.uuid4()) - enqueue_generation(run_generation( - generation_id=generation_id, - profile_id=gen.profile_id, - text=gen.text, - language=gen.language, - engine=gen.engine or "qwen", - model_size=gen.model_size or "1.7B", - seed=gen.seed, - instruct=gen.instruct, - mode="regenerate", - version_id=version_id, - )) + enqueue_generation( + run_generation( + generation_id=generation_id, + profile_id=gen.profile_id, + text=gen.text, + language=gen.language, + engine=gen.engine or "qwen", + model_size=gen.model_size or "1.7B", + seed=gen.seed, + instruct=gen.instruct, + mode="regenerate", + version_id=version_id, + ) + ) return models.GenerationResponse.model_validate(gen) @@ -853,7 +855,7 @@ async def regenerate_generation(generation_id: str, db: Session = Depends(get_db @app.get("/generate/{generation_id}/status") async def get_generation_status(generation_id: str, db: Session = Depends(get_db)): """SSE endpoint that streams generation status updates. - + Polls the DB every second and yields the current status. Closes when the generation reaches 'completed' or 'failed'. """ @@ -914,11 +916,14 @@ async def stream_speech( model_size = data.model_size or "1.7B" from .backends import ensure_model_cached_or_raise, load_engine_model, engine_needs_trim + await ensure_model_cached_or_raise(engine, model_size) await load_engine_model(engine, model_size) voice_prompt = await profiles.create_voice_prompt_for_profile( - data.profile_id, db, engine=engine, + data.profile_id, + db, + engine=engine, ) from .utils.chunked_tts import generate_chunked @@ -926,6 +931,7 @@ async def stream_speech( trim_fn = None if engine_needs_trim(engine): from .utils.audio import trim_tts_output + trim_fn = trim_tts_output audio, sample_rate = await generate_chunked( @@ -942,6 +948,7 @@ async def stream_speech( if data.normalize: from .utils.audio import normalize_audio + audio = normalize_audio(audio) wav_bytes = tts.audio_to_wav_bytes(audio, sample_rate) @@ -959,10 +966,6 @@ async def stream_speech( ) -# ============================================ -# HISTORY ENDPOINTS -# ============================================ - @app.get("/history", response_model=models.HistoryListResponse) async def list_history( profile_id: Optional[str] = None, @@ -995,16 +998,15 @@ async def import_generation( """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" + 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 @@ -1021,19 +1023,16 @@ async def get_generation( ): """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() - + 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, @@ -1086,23 +1085,21 @@ async def export_generation( 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() + 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": _safe_content_disposition("attachment", filename) - } + headers={"Content-Disposition": _safe_content_disposition("attachment", filename)}, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -1119,30 +1116,24 @@ async def export_generation_audio( 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() + 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": _safe_content_disposition("attachment", filename) - } + headers={"Content-Disposition": _safe_content_disposition("attachment", filename)}, ) -# ============================================ -# TRANSCRIPTION ENDPOINTS -# ============================================ - @app.post("/transcribe", response_model=models.TranscriptionResponse) async def transcribe_audio( file: UploadFile = File(...), @@ -1154,13 +1145,14 @@ async def transcribe_audio( content = await file.read() tmp.write(content) tmp_path = tmp.name - + try: # Get audio duration from .utils.audio import load_audio + audio, sr = await asyncio.to_thread(load_audio, tmp_path) duration = len(audio) / sr - + # Transcribe whisper_model = transcribe.get_whisper_model() @@ -1175,6 +1167,7 @@ async def transcribe_audio( # 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 @@ -1195,17 +1188,17 @@ async def transcribe_audio( detail={ "message": f"Whisper model {model_size} is being downloaded. Please wait and try again.", "model_name": progress_model_name, - "downloading": True - } + "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: @@ -1213,10 +1206,6 @@ async def transcribe_audio( 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.""" @@ -1320,7 +1309,9 @@ async def reorder_story_items( """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") + raise HTTPException( + status_code=400, detail="Invalid reorder request - ensure all generation IDs belong to this story" + ) return items @@ -1404,25 +1395,23 @@ async def export_story_audio( 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() + 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": _safe_content_disposition("attachment", filename) - } + headers={"Content-Disposition": _safe_content_disposition("attachment", filename)}, ) except HTTPException: raise @@ -1430,10 +1419,6 @@ async def export_story_audio( raise HTTPException(status_code=500, detail=str(e)) -# ============================================ -# EFFECTS & VERSIONS -# ============================================ - @app.post("/effects/preview/{generation_id}") async def preview_effects( generation_id: str, @@ -1473,6 +1458,7 @@ async def preview_effects( # Write to in-memory buffer import soundfile as sf + buf = io.BytesIO() await asyncio.to_thread(lambda: sf.write(buf, processed, sample_rate, format="WAV")) buf.seek(0) @@ -1491,15 +1477,15 @@ async def preview_effects( async def get_available_effects(): """List all available effect types with parameter definitions.""" from .utils.effects import get_available_effects as _get_effects - return models.AvailableEffectsResponse(effects=[ - models.AvailableEffect(**e) for e in _get_effects() - ]) + + return models.AvailableEffectsResponse(effects=[models.AvailableEffect(**e) for e in _get_effects()]) @app.get("/effects/presets", response_model=List[models.EffectPresetResponse]) async def list_effect_presets(db: Session = Depends(get_db)): """List all effect presets (built-in + user-created).""" from . import effects as effects_mod + return effects_mod.list_presets(db) @@ -1507,6 +1493,7 @@ async def list_effect_presets(db: Session = Depends(get_db)): async def get_effect_preset(preset_id: str, db: Session = Depends(get_db)): """Get a specific effect preset.""" from . import effects as effects_mod + preset = effects_mod.get_preset(preset_id, db) if not preset: raise HTTPException(status_code=404, detail="Preset not found") @@ -1520,6 +1507,7 @@ async def create_effect_preset( ): """Create a new effect preset.""" from . import effects as effects_mod + try: return effects_mod.create_preset(data, db) except ValueError as e: @@ -1534,6 +1522,7 @@ async def update_effect_preset( ): """Update an effect preset.""" from . import effects as effects_mod + try: result = effects_mod.update_preset(preset_id, data, db) if not result: @@ -1547,6 +1536,7 @@ async def update_effect_preset( async def delete_effect_preset(preset_id: str, db: Session = Depends(get_db)): """Delete a user effect preset.""" from . import effects as effects_mod + try: if not effects_mod.delete_preset(preset_id, db): raise HTTPException(status_code=404, detail="Preset not found") @@ -1569,6 +1559,7 @@ async def list_generation_versions( raise HTTPException(status_code=404, detail="Generation not found") from . import versions as versions_mod + return versions_mod.list_versions(generation_id, db) @@ -1602,16 +1593,12 @@ async def apply_effects_to_generation( all_versions = versions_mod.list_versions(generation_id, db) source_version_id = data.source_version_id if source_version_id: - source_version = next( - (v for v in all_versions if v.id == source_version_id), None - ) + source_version = next((v for v in all_versions if v.id == source_version_id), None) if not source_version: raise HTTPException(status_code=404, detail="Source version not found") source_path = source_version.audio_path else: - clean_version = next( - (v for v in all_versions if v.effects_chain is None), None - ) + clean_version = next((v for v in all_versions if v.effects_chain is None), None) if not clean_version: source_path = gen.audio_path else: @@ -1724,6 +1711,7 @@ async def update_profile_effects( if data.effects_chain is not None: from .utils.effects import validate_effects_chain + chain_dicts = [e.model_dump() for e in data.effects_chain] error = validate_effects_chain(chain_dicts) if error: @@ -1739,21 +1727,17 @@ async def update_profile_effects( return _profile_to_response(profile) -# ============================================ -# FILE SERVING -# ============================================ - @app.get("/audio/{generation_id}") async def get_audio(generation_id: str, db: Session = Depends(get_db)): """Serve generated audio file (serves the default version).""" 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", @@ -1765,15 +1749,15 @@ async def get_audio(generation_id: str, db: Session = Depends(get_db)): 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", @@ -1781,10 +1765,6 @@ async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)): ) -# ============================================ -# MODEL MANAGEMENT -# ============================================ - @app.post("/models/load") async def load_model(model_size: str = "1.7B"): """Manually load TTS model.""" @@ -1828,14 +1808,14 @@ async def unload_model_by_name(model_name: str): 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", @@ -1851,6 +1831,7 @@ async def get_model_progress(model_name: str): async def get_models_cache_dir(): """Get the path to the HuggingFace model cache directory.""" from huggingface_hub import constants as hf_constants + return {"path": str(Path(hf_constants.HF_HUB_CACHE))} @@ -1866,6 +1847,7 @@ def _get_dir_size(path: Path) -> int: def _copy_with_progress(src: Path, dst: Path, progress_manager, copied_so_far: int, total_bytes: int) -> int: """Copy a directory tree with byte-level progress tracking.""" import shutil + dst.mkdir(parents=True, exist_ok=True) for item in src.iterdir(): dest_item = dst / item.name @@ -1876,8 +1858,11 @@ def _copy_with_progress(src: Path, dst: Path, progress_manager, copied_so_far: i shutil.copy2(str(item), str(dest_item)) copied_so_far += size progress_manager.update_progress( - "migration", copied_so_far, total_bytes, - filename=item.name, status="downloading", + "migration", + copied_so_far, + total_bytes, + filename=item.name, + status="downloading", ) return copied_so_far @@ -1924,15 +1909,20 @@ async def migrate_models(request: models.ModelMigrateRequest): shutil.move(str(item), str(dest_item)) moved += 1 progress_manager.update_progress( - "migration", i + 1, total, - filename=item.name, status="downloading", + "migration", + i + 1, + total, + filename=item.name, + status="downloading", ) except Exception as e: errors.append(f"{item.name}: {str(e)}") else: # Cross-filesystem: copy with byte-level progress, then delete source total_bytes = sum(_get_dir_size(d) for d in model_dirs) - progress_manager.update_progress("migration", 0, total_bytes, filename="Calculating...", status="downloading") + progress_manager.update_progress( + "migration", 0, total_bytes, filename="Calculating...", status="downloading" + ) copied = 0 for item in model_dirs: @@ -1987,20 +1977,21 @@ async def get_model_status(): """Get status of all available models.""" from huggingface_hub import constants as hf_constants from pathlib import Path - + backend_type = get_backend_type() task_manager = get_task_manager() - + # Get set of currently downloading model names active_download_names = {task.model_name for task in task_manager.get_active_downloads()} - + # 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 - + from .backends import get_all_model_configs, check_model_loaded registry_configs = get_all_model_configs() @@ -2014,14 +2005,14 @@ async def get_model_status(): } for cfg in registry_configs ] - + # Build a mapping of model_name -> hf_repo_id so we can check if shared repos are downloading model_to_repo = {cfg["model_name"]: cfg["hf_repo_id"] for cfg in model_configs} - + # Get the set of hf_repo_ids that are currently being downloaded # This handles the case where multiple models share the same repo (e.g., 0.6B and 1.7B on MLX) active_download_repos = {model_to_repo.get(name) for name in active_download_names if name in model_to_repo} - + # Get HuggingFace cache info (if available) cache_info = None if use_scan_cache: @@ -2030,15 +2021,15 @@ async def get_model_status(): 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"] @@ -2050,12 +2041,12 @@ async def get_model_status(): for rev in repo.revisions: for f in rev.files: fname = f.file_name.lower() - if fname.endswith(('.safetensors', '.bin', '.pt', '.pth', '.npz')): + if fname.endswith((".safetensors", ".bin", ".pt", ".pth", ".npz")): has_model_weights = True break if has_model_weights: break - + # Also check for .incomplete files in blobs directory (downloads in progress) has_incomplete = False try: @@ -2065,7 +2056,7 @@ async def get_model_status(): has_incomplete = any(blobs_dir.glob("*.incomplete")) except Exception: pass - + # Only mark as downloaded if we have model weights AND no incomplete files if has_model_weights and not has_incomplete: downloaded = True @@ -2076,18 +2067,18 @@ async def get_model_status(): 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 .incomplete files - if any exist, download is still in progress blobs_dir = repo_cache / "blobs" has_incomplete = blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")) - + if not has_incomplete: # Check for actual model weight files (not just index files) # in the snapshots directory (symlinks to completed blobs) @@ -2095,73 +2086,78 @@ async def get_model_status(): has_model_files = False if snapshots_dir.exists(): has_model_files = ( - any(snapshots_dir.rglob("*.bin")) or - any(snapshots_dir.rglob("*.safetensors")) or - any(snapshots_dir.rglob("*.pt")) or - any(snapshots_dir.rglob("*.pth")) or - any(snapshots_dir.rglob("*.npz")) + any(snapshots_dir.rglob("*.bin")) + or any(snapshots_dir.rglob("*.safetensors")) + or any(snapshots_dir.rglob("*.pt")) + or any(snapshots_dir.rglob("*.pth")) + or any(snapshots_dir.rglob("*.npz")) ) - + if has_model_files: downloaded = True # Calculate size (exclude .incomplete files) try: total_size = sum( - f.stat().st_size for f in repo_cache.rglob("*") - if f.is_file() and not f.name.endswith('.incomplete') + f.stat().st_size + for f in repo_cache.rglob("*") + if f.is_file() and not f.name.endswith(".incomplete") ) size_mb = total_size / (1024 * 1024) except Exception: pass except Exception: pass - + # Method 3 removed - checking for config.json is too lenient # Methods 1 and 2 properly verify that model weight files exist - + # Check if loaded in memory try: loaded = config["check_loaded"]() except Exception: loaded = False - + # Check if this model (or its shared repo) is currently being downloaded is_downloading = config["hf_repo_id"] in active_download_repos - + # If downloading, don't report as downloaded (partial files exist) if is_downloading: downloaded = False size_mb = None # Don't show partial size during download - - statuses.append(models.ModelStatus( - model_name=config["model_name"], - display_name=config["display_name"], - hf_repo_id=config["hf_repo_id"], - downloaded=downloaded, - downloading=is_downloading, - size_mb=size_mb, - loaded=loaded, - )) + + statuses.append( + models.ModelStatus( + model_name=config["model_name"], + display_name=config["display_name"], + hf_repo_id=config["hf_repo_id"], + downloaded=downloaded, + downloading=is_downloading, + 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 - + # Check if this model (or its shared repo) is currently being downloaded is_downloading = config["hf_repo_id"] in active_download_repos - - statuses.append(models.ModelStatus( - model_name=config["model_name"], - display_name=config["display_name"], - hf_repo_id=config["hf_repo_id"], - downloaded=False, # Assume not downloaded if check failed - downloading=is_downloading, - size_mb=None, - loaded=loaded, - )) - + + statuses.append( + models.ModelStatus( + model_name=config["model_name"], + display_name=config["display_name"], + hf_repo_id=config["hf_repo_id"], + downloaded=False, # Assume not downloaded if check failed + downloading=is_downloading, + size_mb=None, + loaded=loaded, + ) + ) + return models.ModelStatusListResponse(models=statuses) @@ -2179,7 +2175,7 @@ async def trigger_model_download(request: models.ModelDownloadRequest): raise HTTPException(status_code=400, detail=f"Unknown model: {request.model_name}") load_func = get_model_load_func(config) - + async def download_in_background(): """Download model in background without blocking the HTTP request.""" try: @@ -2194,7 +2190,7 @@ async def trigger_model_download(request: models.ModelDownloadRequest): # Start tracking download task_manager.start_download(request.model_name) - + # Initialize progress state so SSE endpoint has initial data to send. # This fixes a race condition where the frontend connects to SSE before # any progress callbacks have fired (especially for large models like Qwen @@ -2256,7 +2252,7 @@ async def delete_model(model_name: str): import shutil import os from huggingface_hub import constants as hf_constants - + from .backends import get_model_config, unload_model_by_config config = get_model_config(model_name) @@ -2268,26 +2264,23 @@ async def delete_model(model_name: str): try: # Unload model if currently loaded unload_model_by_config(config) - + # 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)}" - ) - + 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: @@ -2307,33 +2300,29 @@ async def clear_cache(): raise HTTPException(status_code=500, detail=f"Failed to clear cache: {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: # Prefer task error, fall back to progress manager error error = task.error @@ -2349,62 +2338,65 @@ async def get_active_tasks(): pm_data = progress_manager._progress.get(model_name) if pm_data: prog = pm_data - active_downloads.append(models.ActiveDownloadTask( - model_name=model_name, - status=task.status, - started_at=task.started_at, - error=error, - progress=prog.get("progress"), - current=prog.get("current"), - total=prog.get("total"), - filename=prog.get("filename"), - )) + active_downloads.append( + models.ActiveDownloadTask( + model_name=model_name, + status=task.status, + started_at=task.started_at, + error=error, + progress=prog.get("progress"), + current=prog.get("current"), + total=prog.get("total"), + filename=prog.get("filename"), + ) + ) 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')) + 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, - error=progress.get("error"), - progress=progress.get("progress"), - current=progress.get("current"), - total=progress.get("total"), - filename=progress.get("filename"), - )) - + + active_downloads.append( + models.ActiveDownloadTask( + model_name=model_name, + status=progress.get("status", "downloading"), + started_at=started_at, + error=progress.get("error"), + progress=progress.get("progress"), + current=progress.get("current"), + total=progress.get("total"), + filename=progress.get("filename"), + ) + ) + # 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, - )) - + 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, ) -# ============================================ -# CUDA BACKEND MANAGEMENT -# ============================================ - @app.get("/backend/cuda-status") async def get_cuda_status(): """Get CUDA backend download/availability status.""" from . import cuda_download + return cuda_download.get_cuda_status() @@ -2422,6 +2414,7 @@ async def download_cuda_backend(): await cuda_download.download_cuda_binary() except Exception as e: import logging + logging.getLogger(__name__).error(f"CUDA download failed: {e}") create_background_task(_download()) @@ -2466,21 +2459,17 @@ async def get_cuda_download_progress(): ) -# ============================================ -# STARTUP & SHUTDOWN -# ============================================ - def _get_gpu_status() -> str: """Get GPU availability status.""" backend_type = get_backend_type() if torch.cuda.is_available(): device_name = torch.cuda.get_device_name(0) # Check if this is ROCm (AMD) or CUDA (NVIDIA) - is_rocm = hasattr(torch.version, 'hip') and torch.version.hip is not None + is_rocm = hasattr(torch.version, "hip") and torch.version.hip is not None if is_rocm: return f"ROCm ({device_name})" return f"CUDA ({device_name})" - elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available(): + elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): return "MPS (Apple Silicon)" elif backend_type == "mlx": return "Metal (Apple Silicon via MLX)" @@ -2501,9 +2490,12 @@ async def startup_event(): # from a previous process that was killed mid-generation try: from sqlalchemy import text as sa_text + db = next(get_db()) result = db.execute( - sa_text("UPDATE generations SET status = 'failed', error = 'Server was shut down during generation' WHERE status = 'generating'") + sa_text( + "UPDATE generations SET status = 'failed', error = 'Server was shut down during generation' WHERE status = 'generating'" + ) ) if result.rowcount > 0: print(f"Marked {result.rowcount} stale generation(s) as failed") @@ -2517,6 +2509,7 @@ async def startup_event(): # Auto-update CUDA binary if installed but outdated from .cuda_download import check_and_update_cuda_binary + create_background_task(check_and_update_cuda_binary()) # Initialize progress manager with main event loop for thread-safe operations @@ -2530,6 +2523,7 @@ async def startup_event(): # 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}") @@ -2547,10 +2541,6 @@ async def shutdown_event(): transcribe.unload_whisper_model() -# ============================================ -# MAIN -# ============================================ - if __name__ == "__main__": parser = argparse.ArgumentParser(description="voicebox backend server") parser.add_argument( diff --git a/backend/models.py b/backend/models.py index 630f67f7..4814c7e5 100644 --- a/backend/models.py +++ b/backend/models.py @@ -9,13 +9,17 @@ from datetime import datetime class VoiceProfileCreate(BaseModel): """Request model for creating a voice profile.""" + name: str = Field(..., min_length=1, max_length=100) description: Optional[str] = Field(None, max_length=500) - language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$") + language: str = Field( + default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$" + ) class VoiceProfileResponse(BaseModel): """Response model for voice profile.""" + id: str name: str description: Optional[str] @@ -33,16 +37,19 @@ class VoiceProfileResponse(BaseModel): class ProfileSampleCreate(BaseModel): """Request model for adding a sample to a profile.""" + reference_text: str = Field(..., min_length=1, max_length=1000) class ProfileSampleUpdate(BaseModel): """Request model for updating a profile sample.""" + reference_text: str = Field(..., min_length=1, max_length=1000) class ProfileSampleResponse(BaseModel): """Response model for profile sample.""" + id: str profile_id: str audio_path: str @@ -54,6 +61,7 @@ class ProfileSampleResponse(BaseModel): class GenerationRequest(BaseModel): """Request model for voice generation.""" + profile_id: str text: str = Field(..., min_length=1, max_length=50000) language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he)$") @@ -61,14 +69,21 @@ class GenerationRequest(BaseModel): model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$") instruct: Optional[str] = Field(None, max_length=500) engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo)$") - max_chunk_chars: int = Field(default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting") - crossfade_ms: int = Field(default=50, ge=0, le=500, description="Crossfade duration in ms between chunks (0 for hard cut)") + max_chunk_chars: int = Field( + default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting" + ) + crossfade_ms: int = Field( + default=50, ge=0, le=500, description="Crossfade duration in ms between chunks (0 for hard cut)" + ) normalize: bool = Field(default=True, description="Normalize output audio volume") - effects_chain: Optional[List["EffectConfig"]] = Field(None, description="Effects chain to apply after generation (overrides profile default)") + effects_chain: Optional[List["EffectConfig"]] = Field( + None, description="Effects chain to apply after generation (overrides profile default)" + ) class GenerationResponse(BaseModel): """Response model for voice generation.""" + id: str profile_id: str text: str @@ -92,6 +107,7 @@ class GenerationResponse(BaseModel): class HistoryQuery(BaseModel): """Query model for generation history.""" + profile_id: Optional[str] = None search: Optional[str] = None limit: int = Field(default=50, ge=1, le=100) @@ -100,6 +116,7 @@ class HistoryQuery(BaseModel): class HistoryResponse(BaseModel): """Response model for history entry (includes profile name).""" + id: str profile_id: str profile_name: str @@ -124,23 +141,27 @@ class HistoryResponse(BaseModel): class HistoryListResponse(BaseModel): """Response model for history list.""" + items: List[HistoryResponse] total: int class TranscriptionRequest(BaseModel): """Request model for audio transcription.""" + language: Optional[str] = Field(None, pattern="^(en|zh)$") class TranscriptionResponse(BaseModel): """Response model for transcription.""" + text: str duration: float class HealthResponse(BaseModel): """Response model for health check.""" + status: str model_loaded: bool model_downloaded: Optional[bool] = None # Whether model is cached/downloaded @@ -154,6 +175,7 @@ class HealthResponse(BaseModel): class DirectoryCheck(BaseModel): """Health status for a single directory.""" + path: str exists: bool writable: bool @@ -162,6 +184,7 @@ class DirectoryCheck(BaseModel): class FilesystemHealthResponse(BaseModel): """Response model for filesystem health check.""" + healthy: bool disk_free_mb: Optional[float] = None disk_total_mb: Optional[float] = None @@ -170,6 +193,7 @@ class FilesystemHealthResponse(BaseModel): class ModelStatus(BaseModel): """Response model for model status.""" + model_name: str display_name: str hf_repo_id: Optional[str] = None # HuggingFace repository ID @@ -181,33 +205,38 @@ class ModelStatus(BaseModel): class ModelStatusListResponse(BaseModel): """Response model for model status list.""" + models: List[ModelStatus] class ModelDownloadRequest(BaseModel): """Request model for triggering model download.""" + model_name: str class ModelMigrateRequest(BaseModel): """Request model for migrating models to a new directory.""" + destination: str class ActiveDownloadTask(BaseModel): """Response model for active download task.""" + model_name: str status: str started_at: datetime error: Optional[str] = None progress: Optional[float] = None # 0-100 percentage - current: Optional[int] = None # bytes downloaded - total: Optional[int] = None # total bytes - filename: Optional[str] = None # current file being downloaded + current: Optional[int] = None # bytes downloaded + total: Optional[int] = None # total bytes + filename: Optional[str] = None # current file being downloaded class ActiveGenerationTask(BaseModel): """Response model for active generation task.""" + task_id: str profile_id: str text_preview: str @@ -216,24 +245,28 @@ class ActiveGenerationTask(BaseModel): class ActiveTasksResponse(BaseModel): """Response model for active tasks.""" + downloads: List[ActiveDownloadTask] generations: List[ActiveGenerationTask] class AudioChannelCreate(BaseModel): """Request model for creating an audio channel.""" + name: str = Field(..., min_length=1, max_length=100) device_ids: List[str] = Field(default_factory=list) class AudioChannelUpdate(BaseModel): """Request model for updating an audio channel.""" + name: Optional[str] = Field(None, min_length=1, max_length=100) device_ids: Optional[List[str]] = None class AudioChannelResponse(BaseModel): """Response model for audio channel.""" + id: str name: str is_default: bool @@ -246,22 +279,26 @@ class AudioChannelResponse(BaseModel): class ChannelVoiceAssignment(BaseModel): """Request model for assigning voices to a channel.""" + profile_ids: List[str] class ProfileChannelAssignment(BaseModel): """Request model for assigning channels to a profile.""" + channel_ids: List[str] class StoryCreate(BaseModel): """Request model for creating a story.""" + name: str = Field(..., min_length=1, max_length=100) description: Optional[str] = Field(None, max_length=500) class StoryResponse(BaseModel): """Response model for story (list view).""" + id: str name: str description: Optional[str] @@ -275,6 +312,7 @@ class StoryResponse(BaseModel): class StoryItemDetail(BaseModel): """Detail model for story item with generation info.""" + id: str story_id: str generation_id: str @@ -304,6 +342,7 @@ class StoryItemDetail(BaseModel): class StoryDetailResponse(BaseModel): """Response model for story with items.""" + id: str name: str description: Optional[str] @@ -317,6 +356,7 @@ class StoryDetailResponse(BaseModel): class StoryItemCreate(BaseModel): """Request model for adding a generation to a story.""" + generation_id: str start_time_ms: Optional[int] = None # If not provided, will be calculated automatically track: Optional[int] = 0 # Track number (0 = main track) @@ -324,48 +364,52 @@ class StoryItemCreate(BaseModel): class StoryItemUpdateTime(BaseModel): """Request model for updating a story item's timecode.""" + generation_id: str start_time_ms: int = Field(..., ge=0) class StoryItemBatchUpdate(BaseModel): """Request model for batch updating story item timecodes.""" + updates: List[StoryItemUpdateTime] class StoryItemReorder(BaseModel): """Request model for reordering story items.""" + generation_ids: List[str] = Field(..., min_length=1) class StoryItemMove(BaseModel): """Request model for moving a story item (position and/or track).""" + start_time_ms: int = Field(..., ge=0) track: int = 0 class StoryItemTrim(BaseModel): """Request model for trimming a story item.""" + trim_start_ms: int = Field(..., ge=0) trim_end_ms: int = Field(..., ge=0) class StoryItemSplit(BaseModel): """Request model for splitting a story item.""" + split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start) class StoryItemVersionUpdate(BaseModel): """Request model for setting a story item's pinned version.""" + version_id: Optional[str] = None # null = use generation default -# ============================================ -# Effects & Versions -# ============================================ - class EffectConfig(BaseModel): """A single effect in an effects chain.""" + type: str enabled: bool = True params: dict = Field(default_factory=dict) @@ -373,11 +417,13 @@ class EffectConfig(BaseModel): class EffectsChain(BaseModel): """An ordered list of effects to apply.""" + effects: List[EffectConfig] = Field(default_factory=list) class EffectPresetCreate(BaseModel): """Request model for creating an effect preset.""" + name: str = Field(..., min_length=1, max_length=100) description: Optional[str] = Field(None, max_length=500) effects_chain: List[EffectConfig] @@ -385,6 +431,7 @@ class EffectPresetCreate(BaseModel): class EffectPresetUpdate(BaseModel): """Request model for updating an effect preset.""" + name: Optional[str] = Field(None, min_length=1, max_length=100) description: Optional[str] = None effects_chain: Optional[List[EffectConfig]] = None @@ -392,6 +439,7 @@ class EffectPresetUpdate(BaseModel): class EffectPresetResponse(BaseModel): """Response model for effect preset.""" + id: str name: str description: Optional[str] = None @@ -405,6 +453,7 @@ class EffectPresetResponse(BaseModel): class GenerationVersionResponse(BaseModel): """Response model for a generation version.""" + id: str generation_id: str label: str @@ -420,19 +469,24 @@ class GenerationVersionResponse(BaseModel): class ApplyEffectsRequest(BaseModel): """Request to apply effects to an existing generation.""" + effects_chain: List[EffectConfig] - source_version_id: Optional[str] = Field(None, description="Version to use as source audio (defaults to clean/original)") + source_version_id: Optional[str] = Field( + None, description="Version to use as source audio (defaults to clean/original)" + ) label: Optional[str] = Field(None, max_length=100, description="Label for this version (auto-generated if omitted)") set_as_default: bool = Field(default=True, description="Set this version as the default") class ProfileEffectsUpdate(BaseModel): """Request to update the default effects chain on a profile.""" + effects_chain: Optional[List[EffectConfig]] = Field(None, description="Effects chain (null to remove)") class AvailableEffectParam(BaseModel): """Description of a single effect parameter.""" + default: float min: float max: float @@ -442,6 +496,7 @@ class AvailableEffectParam(BaseModel): class AvailableEffect(BaseModel): """Description of an available effect type.""" + type: str label: str description: str @@ -450,4 +505,5 @@ class AvailableEffect(BaseModel): class AvailableEffectsResponse(BaseModel): """Response listing all available effect types.""" + effects: List[AvailableEffect] diff --git a/backend/profiles.py b/backend/profiles.py index 9f8f3b5d..ab8941c4 100644 --- a/backend/profiles.py +++ b/backend/profiles.py @@ -43,6 +43,7 @@ def _profile_to_response( effects_chain = [EffectConfig(**e) for e in raw] except Exception as e: import logging + logging.warning(f"Failed to parse effects_chain for profile {profile.id}: {e}") return VoiceProfileResponse( id=profile.id, @@ -75,12 +76,10 @@ async def create_profile( Raises: ValueError: If a profile with the same name already exists """ - # Check if profile name already exists existing_profile = db.query(DBVoiceProfile).filter_by(name=data.name).first() if existing_profile: raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.") - # Create profile in database db_profile = DBVoiceProfile( id=str(uuid.uuid4()), name=data.name, @@ -94,7 +93,6 @@ async def create_profile( db.commit() db.refresh(db_profile) - # Create profile directory profile_dir = config.get_profiles_dir() / db_profile.id profile_dir.mkdir(parents=True, exist_ok=True) @@ -109,56 +107,50 @@ async def add_profile_sample( ) -> ProfileSampleResponse: """ Add a sample to a voice profile. - + Args: profile_id: Profile ID audio_path: Path to temporary audio file reference_text: Transcript of audio db: Database session - + Returns: Created sample """ - # Validate profile exists profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first() if not profile: raise ValueError(f"Profile {profile_id} not found") - - # Validate audio + is_valid, error_msg = validate_reference_audio(audio_path) if not is_valid: raise ValueError(f"Invalid reference audio: {error_msg}") - - # Create sample ID and directory + sample_id = str(uuid.uuid4()) profile_dir = config.get_profiles_dir() / profile_id profile_dir.mkdir(parents=True, exist_ok=True) - - # Copy audio file to profile directory + dest_path = profile_dir / f"{sample_id}.wav" audio, sr = load_audio(audio_path) save_audio(audio, str(dest_path), sr) - - # Create database entry + db_sample = DBProfileSample( id=sample_id, profile_id=profile_id, audio_path=str(dest_path), reference_text=reference_text, ) - + db.add(db_sample) - - # Update profile timestamp + profile.updated_at = datetime.utcnow() - + db.commit() db.refresh(db_sample) - + # Invalidate combined audio cache for this profile # Since a new sample was added, any cached combined audio is now stale clear_profile_cache(profile_id) - + return ProfileSampleResponse.model_validate(db_sample) @@ -168,18 +160,18 @@ async def get_profile( ) -> Optional[VoiceProfileResponse]: """ Get a voice profile by ID. - + Args: profile_id: Profile ID db: Database session - + Returns: Profile or None if not found """ profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first() if not profile: return None - + return _profile_to_response(profile) @@ -189,11 +181,11 @@ async def get_profile_samples( ) -> List[ProfileSampleResponse]: """ Get all samples for a profile. - + Args: profile_id: Profile ID db: Database session - + Returns: List of samples """ @@ -204,33 +196,27 @@ async def get_profile_samples( async def list_profiles(db: Session) -> List[VoiceProfileResponse]: """ List all voice profiles with generation and sample counts. - + Args: db: Database session - + Returns: List of profiles """ - profiles = db.query(DBVoiceProfile).order_by( - DBVoiceProfile.created_at.desc() - ).all() + profiles = db.query(DBVoiceProfile).order_by(DBVoiceProfile.created_at.desc()).all() if not profiles: return [] # Batch-fetch generation counts gen_counts_rows = ( - db.query(DBGeneration.profile_id, func.count(DBGeneration.id)) - .group_by(DBGeneration.profile_id) - .all() + db.query(DBGeneration.profile_id, func.count(DBGeneration.id)).group_by(DBGeneration.profile_id).all() ) gen_counts = {row[0]: row[1] for row in gen_counts_rows} # Batch-fetch sample counts sample_counts_rows = ( - db.query(DBProfileSample.profile_id, func.count(DBProfileSample.id)) - .group_by(DBProfileSample.profile_id) - .all() + db.query(DBProfileSample.profile_id, func.count(DBProfileSample.id)).group_by(DBProfileSample.profile_id).all() ) sample_counts = {row[0]: row[1] for row in sample_counts_rows} @@ -267,13 +253,11 @@ async def update_profile( if not profile: return None - # Check if the new name conflicts with another profile if profile.name != data.name: existing_profile = db.query(DBVoiceProfile).filter_by(name=data.name).first() if existing_profile: raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.") - # Update fields profile.name = data.name profile.description = data.description profile.language = data.language @@ -291,33 +275,30 @@ async def delete_profile( ) -> bool: """ Delete a voice profile and all associated data. - + Args: profile_id: Profile ID db: Database session - + Returns: True if deleted, False if not found """ profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first() if not profile: return False - - # Delete samples from database + db.query(DBProfileSample).filter_by(profile_id=profile_id).delete() - - # Delete profile from database + db.delete(profile) db.commit() - - # Delete profile directory + profile_dir = config.get_profiles_dir() / profile_id if profile_dir.exists(): shutil.rmtree(profile_dir) - + # Clean up combined audio cache files for this profile clear_profile_cache(profile_id) - + return True @@ -327,34 +308,32 @@ async def delete_profile_sample( ) -> bool: """ Delete a profile sample. - + Args: sample_id: Sample ID db: Database session - + Returns: True if deleted, False if not found """ sample = db.query(DBProfileSample).filter_by(id=sample_id).first() if not sample: return False - + # Store profile_id before deleting profile_id = sample.profile_id - - # Delete audio file + audio_path = Path(sample.audio_path) if audio_path.exists(): audio_path.unlink() - - # Delete from database + db.delete(sample) db.commit() - + # Invalidate combined audio cache for this profile # Since the sample set changed, any cached combined audio is now stale clear_profile_cache(profile_id) - + return True @@ -365,30 +344,30 @@ async def update_profile_sample( ) -> Optional[ProfileSampleResponse]: """ Update a profile sample's reference text. - + Args: sample_id: Sample ID reference_text: Updated reference text db: Database session - + Returns: Updated sample or None if not found """ sample = db.query(DBProfileSample).filter_by(id=sample_id).first() if not sample: return None - + # Store profile_id before updating profile_id = sample.profile_id - + sample.reference_text = reference_text db.commit() db.refresh(sample) - + # Invalidate combined audio cache for this profile # Since the reference text changed, cache keys and combined text are now stale clear_profile_cache(profile_id) - + return ProfileSampleResponse.model_validate(sample) @@ -412,7 +391,6 @@ async def create_voice_prompt_for_profile( """ from .backends import get_tts_backend_for_engine - # Get all samples for profile samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all() if not samples: @@ -421,7 +399,6 @@ async def create_voice_prompt_for_profile( tts_model = get_tts_backend_for_engine(engine) if len(samples) == 1: - # Single sample - use directly sample = samples[0] voice_prompt, _ = await tts_model.create_voice_prompt( sample.audio_path, @@ -430,11 +407,9 @@ async def create_voice_prompt_for_profile( ) return voice_prompt else: - # Multiple samples - combine them audio_paths = [s.audio_path for s in samples] reference_texts = [s.reference_text for s in samples] - # Combine audio combined_audio, combined_text = await tts_model.combine_voice_prompts( audio_paths, reference_texts, @@ -443,18 +418,16 @@ async def create_voice_prompt_for_profile( # Save combined audio to cache directory (persistent) # Create a hash of sample IDs to identify this specific combination import hashlib + sample_ids_str = "-".join(sorted([s.id for s in samples])) combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12] - - # Store in cache directory + cache_dir = _get_cache_dir() cache_dir.mkdir(parents=True, exist_ok=True) combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav" - - # Save combined audio + save_audio(combined_audio, str(combined_path), 24000) - # Create prompt from combined audio voice_prompt, _ = await tts_model.create_voice_prompt( str(combined_path), combined_text, @@ -479,17 +452,14 @@ async def upload_avatar( Returns: Updated profile """ - # Validate profile exists profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first() if not profile: raise ValueError(f"Profile {profile_id} not found") - # Validate image is_valid, error_msg = validate_image(image_path) if not is_valid: raise ValueError(error_msg) - # Delete existing avatar if present if profile.avatar_path: old_avatar = Path(profile.avatar_path) if old_avatar.exists(): @@ -497,27 +467,22 @@ async def upload_avatar( # Determine file extension from uploaded file from PIL import Image + with Image.open(image_path) as img: # Normalize JPEG variants (MPO is multi-picture format from some cameras) img_format = img.format - if img_format in ('MPO', 'JPG'): - img_format = 'JPEG' - - ext_map = { - 'PNG': '.png', - 'JPEG': '.jpg', - 'WEBP': '.webp' - } - ext = ext_map.get(img_format, '.png') + if img_format in ("MPO", "JPG"): + img_format = "JPEG" + + ext_map = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp"} + ext = ext_map.get(img_format, ".png") - # Save processed image to profile directory profile_dir = config.get_profiles_dir() / profile_id profile_dir.mkdir(parents=True, exist_ok=True) output_path = profile_dir / f"avatar{ext}" process_avatar(image_path, str(output_path)) - # Update database profile.avatar_path = str(output_path) profile.updated_at = datetime.utcnow() @@ -545,12 +510,10 @@ async def delete_avatar( if not profile or not profile.avatar_path: return False - # Delete avatar file avatar_path = Path(profile.avatar_path) if avatar_path.exists(): avatar_path.unlink() - # Update database profile.avatar_path = None profile.updated_at = datetime.utcnow() diff --git a/backend/services/generation.py b/backend/services/generation.py index 9402074d..6f85cd0c 100644 --- a/backend/services/generation.py +++ b/backend/services/generation.py @@ -81,9 +81,7 @@ async def run_generation( if crossfade_ms is not None: gen_kwargs["crossfade_ms"] = crossfade_ms - audio, sample_rate = await generate_chunked( - tts_model, text, voice_prompt, **gen_kwargs - ) + audio, sample_rate = await generate_chunked(tts_model, text, voice_prompt, **gen_kwargs) # --- Normalize (generate and regenerate always; retry skips) ----- if normalize or mode == "regenerate": @@ -139,11 +137,6 @@ async def run_generation( bg_db.close() -# --------------------------------------------------------------------- -# Mode-specific save helpers (sync, return final audio path) -# --------------------------------------------------------------------- - - def _save_generate( *, generation_id: str, @@ -163,9 +156,7 @@ def _save_generate( clean_audio_path = config.get_generations_dir() / f"{generation_id}.wav" save_audio(audio, str(clean_audio_path), sample_rate) - has_effects = effects_chain and any( - e.get("enabled", True) for e in effects_chain - ) + has_effects = effects_chain and any(e.get("enabled", True) for e in effects_chain) versions_mod.create_version( generation_id=generation_id, @@ -186,9 +177,7 @@ def _save_generate( print(f"Warning: invalid effects chain, skipping: {error_msg}") else: processed_audio = apply_effects(audio, sample_rate, effects_chain) - processed_path = ( - config.get_generations_dir() / f"{generation_id}_processed.wav" - ) + processed_path = config.get_generations_dir() / f"{generation_id}_processed.wav" save_audio(processed_audio, str(processed_path), sample_rate) final_audio_path = str(processed_path) versions_mod.create_version( diff --git a/backend/stories.py b/backend/stories.py index 8d59bc17..cb4dad65 100644 --- a/backend/stories.py +++ b/backend/stories.py @@ -22,7 +22,12 @@ from .models import ( StoryItemSplit, StoryItemVersionUpdate, ) -from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile +from .database import ( + Story as DBStory, + StoryItem as DBStoryItem, + Generation as DBGeneration, + VoiceProfile as DBVoiceProfile, +) from .history import _get_versions_for_generation from .utils.audio import load_audio, save_audio import numpy as np @@ -49,11 +54,11 @@ def _build_item_detail( id=item.id, story_id=item.story_id, generation_id=item.generation_id, - version_id=getattr(item, 'version_id', None), + version_id=getattr(item, "version_id", None), start_time_ms=item.start_time_ms, track=item.track, - trim_start_ms=getattr(item, 'trim_start_ms', 0), - trim_end_ms=getattr(item, 'trim_end_ms', 0), + trim_start_ms=getattr(item, "trim_start_ms", 0), + trim_end_ms=getattr(item, "trim_end_ms", 0), created_at=item.created_at, profile_id=generation.profile_id, profile_name=profile_name, @@ -95,10 +100,7 @@ async def create_story( db.commit() db.refresh(db_story) - # Get item count - item_count = db.query(func.count(DBStoryItem.id)).filter( - DBStoryItem.story_id == db_story.id - ).scalar() + item_count = db.query(func.count(DBStoryItem.id)).filter(DBStoryItem.story_id == db_story.id).scalar() response = StoryResponse.model_validate(db_story) response.item_count = item_count @@ -118,17 +120,15 @@ async def list_stories( List of stories with item counts """ stories = db.query(DBStory).order_by(DBStory.updated_at.desc()).all() - + result = [] for story in stories: - item_count = db.query(func.count(DBStoryItem.id)).filter( - DBStoryItem.story_id == story.id - ).scalar() - + item_count = db.query(func.count(DBStoryItem.id)).filter(DBStoryItem.story_id == story.id).scalar() + response = StoryResponse.model_validate(story) response.item_count = item_count result.append(response) - + return result @@ -150,22 +150,15 @@ async def get_story( if not story: return None - # Get all items ordered by start_time_ms - items = db.query( - DBStoryItem, - DBGeneration, - DBVoiceProfile.name.label('profile_name') - ).join( - DBGeneration, - DBStoryItem.generation_id == DBGeneration.id - ).join( - DBVoiceProfile, - DBGeneration.profile_id == DBVoiceProfile.id - ).filter( - DBStoryItem.story_id == story_id - ).order_by(DBStoryItem.start_time_ms).all() + items = ( + db.query(DBStoryItem, DBGeneration, DBVoiceProfile.name.label("profile_name")) + .join(DBGeneration, DBStoryItem.generation_id == DBGeneration.id) + .join(DBVoiceProfile, DBGeneration.profile_id == DBVoiceProfile.id) + .filter(DBStoryItem.story_id == story_id) + .order_by(DBStoryItem.start_time_ms) + .all() + ) - # Build item details item_details = [] for item, generation, profile_name in items: item_details.append(_build_item_detail(item, generation, profile_name, db)) @@ -202,10 +195,7 @@ async def update_story( db.commit() db.refresh(story) - # Get item count - item_count = db.query(func.count(DBStoryItem.id)).filter( - DBStoryItem.story_id == story.id - ).scalar() + item_count = db.query(func.count(DBStoryItem.id)).filter(DBStoryItem.story_id == story.id).scalar() response = StoryResponse.model_validate(story) response.item_count = item_count @@ -267,10 +257,7 @@ async def add_item_to_story( return None # Check if generation is already in story - existing = db.query(DBStoryItem).filter_by( - story_id=story_id, - generation_id=data.generation_id - ).first() + existing = db.query(DBStoryItem).filter_by(story_id=story_id, generation_id=data.generation_id).first() if existing: # Return existing item profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() @@ -283,18 +270,16 @@ async def add_item_to_story( if data.start_time_ms is not None: start_time_ms = data.start_time_ms else: - # Find the maximum end time on the target track only - existing_items = db.query( - DBStoryItem, - DBGeneration - ).join( - DBGeneration, - DBStoryItem.generation_id == DBGeneration.id - ).filter( - DBStoryItem.story_id == story_id, - DBStoryItem.track == track, - ).all() - + existing_items = ( + db.query(DBStoryItem, DBGeneration) + .join(DBGeneration, DBStoryItem.generation_id == DBGeneration.id) + .filter( + DBStoryItem.story_id == story_id, + DBStoryItem.track == track, + ) + .all() + ) + if not existing_items: start_time_ms = 0 else: @@ -302,7 +287,7 @@ async def add_item_to_story( for item, gen in existing_items: item_end_ms = item.start_time_ms + int(gen.duration * 1000) max_end_time_ms = max(max_end_time_ms, item_end_ms) - + # Add 200ms gap after the last item start_time_ms = max_end_time_ms + 200 @@ -317,10 +302,10 @@ async def add_item_to_story( ) db.add(item) - + # Update story updated_at story.updated_at = datetime.utcnow() - + db.commit() db.refresh(item) @@ -349,10 +334,14 @@ async def move_story_item( Updated item detail or None if not found """ # Get the item - item = db.query(DBStoryItem).filter_by( - id=item_id, - story_id=story_id, - ).first() + item = ( + db.query(DBStoryItem) + .filter_by( + id=item_id, + story_id=story_id, + ) + .first() + ) if not item: return None @@ -395,10 +384,14 @@ async def remove_item_from_story( Returns: True if removed, False if not found """ - item = db.query(DBStoryItem).filter_by( - id=item_id, - story_id=story_id, - ).first() + item = ( + db.query(DBStoryItem) + .filter_by( + id=item_id, + story_id=story_id, + ) + .first() + ) if not item: return False @@ -433,10 +426,14 @@ async def trim_story_item( Updated item detail or None if not found """ # Get the item - item = db.query(DBStoryItem).filter_by( - id=item_id, - story_id=story_id, - ).first() + item = ( + db.query(DBStoryItem) + .filter_by( + id=item_id, + story_id=story_id, + ) + .first() + ) if not item: return None @@ -487,10 +484,14 @@ async def split_story_item( List of two updated item details (original and new) or None if not found/invalid """ # Get the item - item = db.query(DBStoryItem).filter_by( - id=item_id, - story_id=story_id, - ).first() + item = ( + db.query(DBStoryItem) + .filter_by( + id=item_id, + story_id=story_id, + ) + .first() + ) if not item: return None @@ -500,8 +501,8 @@ async def split_story_item( return None # Calculate effective duration and validate split point - current_trim_start = getattr(item, 'trim_start_ms', 0) - current_trim_end = getattr(item, 'trim_end_ms', 0) + current_trim_start = getattr(item, "trim_start_ms", 0) + current_trim_end = getattr(item, "trim_end_ms", 0) original_duration_ms = int(generation.duration * 1000) effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end @@ -520,7 +521,7 @@ async def split_story_item( id=str(uuid.uuid4()), story_id=story_id, generation_id=item.generation_id, # Same generation, different trim - version_id=getattr(item, 'version_id', None), # Preserve pinned version + version_id=getattr(item, "version_id", None), # Preserve pinned version start_time_ms=item.start_time_ms + data.split_time_ms, track=item.track, trim_start_ms=absolute_split_ms, @@ -566,10 +567,14 @@ async def duplicate_story_item( New item detail or None if not found """ # Get the original item - original_item = db.query(DBStoryItem).filter_by( - id=item_id, - story_id=story_id, - ).first() + original_item = ( + db.query(DBStoryItem) + .filter_by( + id=item_id, + story_id=story_id, + ) + .first() + ) if not original_item: return None @@ -579,8 +584,8 @@ async def duplicate_story_item( return None # Calculate effective duration - current_trim_start = getattr(original_item, 'trim_start_ms', 0) - current_trim_end = getattr(original_item, 'trim_end_ms', 0) + current_trim_start = getattr(original_item, "trim_start_ms", 0) + current_trim_end = getattr(original_item, "trim_end_ms", 0) original_duration_ms = int(generation.duration * 1000) effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end @@ -589,7 +594,7 @@ async def duplicate_story_item( id=str(uuid.uuid4()), story_id=story_id, generation_id=original_item.generation_id, # Same generation as original - version_id=getattr(original_item, 'version_id', None), # Preserve pinned version + version_id=getattr(original_item, "version_id", None), # Preserve pinned version start_time_ms=original_item.start_time_ms + effective_duration_ms + 200, # 200ms gap track=original_item.track, trim_start_ms=current_trim_start, @@ -673,19 +678,13 @@ async def reorder_story_items( return None # Get all items for this story with their generation data - items_with_gen = db.query( - DBStoryItem, - DBGeneration, - DBVoiceProfile.name.label('profile_name') - ).join( - DBGeneration, - DBStoryItem.generation_id == DBGeneration.id - ).join( - DBVoiceProfile, - DBGeneration.profile_id == DBVoiceProfile.id - ).filter( - DBStoryItem.story_id == story_id - ).all() + items_with_gen = ( + db.query(DBStoryItem, DBGeneration, DBVoiceProfile.name.label("profile_name")) + .join(DBGeneration, DBStoryItem.generation_id == DBGeneration.id) + .join(DBVoiceProfile, DBGeneration.profile_id == DBVoiceProfile.id) + .filter(DBStoryItem.story_id == story_id) + .all() + ) # Create maps for quick lookup item_map = {item.generation_id: (item, gen, profile_name) for item, gen, profile_name in items_with_gen} @@ -700,13 +699,13 @@ async def reorder_story_items( for gen_id in generation_ids: item, generation, profile_name = item_map[gen_id] - + # Update the item's start time item.start_time_ms = current_time_ms - + # Calculate the duration in ms duration_ms = int(generation.duration * 1000) - + # Move to next position (current end + gap) current_time_ms += duration_ms + gap_ms @@ -738,10 +737,14 @@ async def set_story_item_version( Returns: Updated item detail or None if not found """ - item = db.query(DBStoryItem).filter_by( - id=item_id, - story_id=story_id, - ).first() + item = ( + db.query(DBStoryItem) + .filter_by( + id=item_id, + story_id=story_id, + ) + .first() + ) if not item: return None @@ -752,10 +755,15 @@ async def set_story_item_version( # Validate version_id belongs to this generation if provided if data.version_id: from .database import GenerationVersion as DBGenerationVersion - version = db.query(DBGenerationVersion).filter_by( - id=data.version_id, - generation_id=item.generation_id, - ).first() + + version = ( + db.query(DBGenerationVersion) + .filter_by( + id=data.version_id, + generation_id=item.generation_id, + ) + .first() + ) if not version: return None @@ -793,15 +801,13 @@ async def export_story_audio( return None # Get all items ordered by start_time_ms - items = db.query( - DBStoryItem, - DBGeneration - ).join( - DBGeneration, - DBStoryItem.generation_id == DBGeneration.id - ).filter( - DBStoryItem.story_id == story_id - ).order_by(DBStoryItem.start_time_ms).all() + items = ( + db.query(DBStoryItem, DBGeneration) + .join(DBGeneration, DBStoryItem.generation_id == DBGeneration.id) + .filter(DBStoryItem.story_id == story_id) + .order_by(DBStoryItem.start_time_ms) + .all() + ) if not items: return None @@ -813,8 +819,9 @@ async def export_story_audio( for item, generation in items: # Resolve audio path: use pinned version if set, otherwise generation default resolved_audio_path = generation.audio_path - if getattr(item, 'version_id', None): + if getattr(item, "version_id", None): from .database import GenerationVersion as DBGenerationVersion + version = db.query(DBGenerationVersion).filter_by(id=item.version_id).first() if version: resolved_audio_path = version.audio_path @@ -826,33 +833,37 @@ async def export_story_audio( try: audio, sr = load_audio(str(audio_path), sample_rate=sample_rate) sample_rate = sr # Use actual sample rate from first file - + # Get trim values - trim_start_ms = getattr(item, 'trim_start_ms', 0) - trim_end_ms = getattr(item, 'trim_end_ms', 0) - + trim_start_ms = getattr(item, "trim_start_ms", 0) + trim_end_ms = getattr(item, "trim_end_ms", 0) + # Calculate effective duration original_duration_ms = int(generation.duration * 1000) effective_duration_ms = original_duration_ms - trim_start_ms - trim_end_ms - + # Slice audio based on trim values trim_start_sample = int((trim_start_ms / 1000.0) * sample_rate) trim_end_sample = int((trim_end_ms / 1000.0) * sample_rate) - + # Extract the trimmed portion if trim_end_ms > 0: - trimmed_audio = audio[trim_start_sample:-trim_end_sample] if trim_end_sample > 0 else audio[trim_start_sample:] + trimmed_audio = ( + audio[trim_start_sample:-trim_end_sample] if trim_end_sample > 0 else audio[trim_start_sample:] + ) else: trimmed_audio = audio[trim_start_sample:] - + # Store audio with its timecode info start_time_ms = item.start_time_ms - - audio_data.append({ - 'audio': trimmed_audio, - 'start_time_ms': start_time_ms, - 'duration_ms': effective_duration_ms, - }) + + audio_data.append( + { + "audio": trimmed_audio, + "start_time_ms": start_time_ms, + "duration_ms": effective_duration_ms, + } + ) except Exception: # Skip files that can't be loaded continue @@ -861,33 +872,30 @@ async def export_story_audio( return None # Calculate total duration: max(start_time_ms + duration_ms) - max_end_time_ms = max( - (data['start_time_ms'] + data['duration_ms'] for data in audio_data), - default=0 - ) - + max_end_time_ms = max((data["start_time_ms"] + data["duration_ms"] for data in audio_data), default=0) + # Convert to samples total_samples = int((max_end_time_ms / 1000.0) * sample_rate) - + # Create output buffer initialized to zeros final_audio = np.zeros(total_samples, dtype=np.float32) # Mix each audio segment at its timecode position for data in audio_data: - audio = data['audio'] - start_time_ms = data['start_time_ms'] - + audio = data["audio"] + start_time_ms = data["start_time_ms"] + # Calculate start sample index start_sample = int((start_time_ms / 1000.0) * sample_rate) - + # Ensure we don't exceed buffer bounds audio_length = len(audio) end_sample = min(start_sample + audio_length, total_samples) - + if start_sample < total_samples: # Trim audio if it extends beyond buffer - audio_to_mix = audio[:end_sample - start_sample] - + audio_to_mix = audio[: end_sample - start_sample] + # Mix: add audio to existing buffer (overlapping audio will sum) # Normalize to prevent clipping (simple approach: divide by max) final_audio[start_sample:end_sample] += audio_to_mix @@ -898,14 +906,14 @@ async def export_story_audio( final_audio = final_audio / max_val # Save to temporary file - with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp: + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: tmp_path = tmp.name try: save_audio(final_audio, tmp_path, sample_rate) # Read file bytes - with open(tmp_path, 'rb') as f: + with open(tmp_path, "rb") as f: audio_bytes = f.read() return audio_bytes diff --git a/backend/tests/test_generation_download.py b/backend/tests/test_generation_download.py index 5cbe3fdf..19618ca4 100644 --- a/backend/tests/test_generation_download.py +++ b/backend/tests/test_generation_download.py @@ -37,11 +37,10 @@ async def monitor_sse_stream(model_name: str, timeout: int = 120): if line.startswith("data: "): try: data = json.loads(line[6:]) - print(f"[{timestamp}] โ†’ SSE Event: {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}") - events.append({ - **data, - "_timestamp": timestamp - }) + print( + f"[{timestamp}] โ†’ SSE Event: {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}" + ) + events.append({**data, "_timestamp": timestamp}) # Stop if complete or error if data.get("status") in ("complete", "error"): @@ -74,12 +73,15 @@ async def trigger_generation(profile_id: str, text: str, model_size: str = "1.7B try: async with httpx.AsyncClient(timeout=120) as client: - response = await client.post(url, json={ - "profile_id": profile_id, - "text": text, - "language": "en", - "model_size": model_size, - }) + response = await client.post( + url, + json={ + "profile_id": profile_id, + "text": text, + "language": "en", + "model_size": model_size, + }, + ) print(f"[{_timestamp()}] Response: {response.status_code}") @@ -140,7 +142,7 @@ def _timestamp(): async def test_generation_with_cached_model(): """ Test Case 1: Generation when model is already cached. - + This should NOT show any download progress events. If it does, that's the UX bug we're trying to fix. """ @@ -194,7 +196,7 @@ async def test_generation_with_cached_model(): async def test_generation_with_fresh_download(): """ Test Case 2: Generation when model needs to be downloaded. - + This SHOULD show download progress events. """ print("\n" + "=" * 80) @@ -292,24 +294,6 @@ async def main(): print(" Users see progress events even when the model is already cached,") print(" making them think the model is downloading again.") - # Test Case 2: Fresh download (optional, commented out by default) - # Uncomment if you want to test download progress - # print("\n" + "๐Ÿงช " * 20) - # events_download = await test_generation_with_fresh_download() - # - # print("\n" + "=" * 80) - # print("TEST CASE 2 RESULTS: Generation with Model Download") - # print("=" * 80) - # - # if not events_download: - # print("โ„น Model was already cached, no download occurred") - # else: - # print(f"โœ“ Received {len(events_download)} download progress events") - # print("\nDownload Timeline:") - # for i, event in enumerate(events_download, 1): - # timestamp = event.pop("_timestamp", "??:??:??.???") - # print(f" {i}. [{timestamp}] {event}") - print("\n" + "=" * 80) print("Test Complete!") print("=" * 80) diff --git a/backend/utils/chunked_tts.py b/backend/utils/chunked_tts.py index 53a454c6..1f43379e 100644 --- a/backend/utils/chunked_tts.py +++ b/backend/utils/chunked_tts.py @@ -58,11 +58,6 @@ _ABBREVIATIONS = frozenset( _PARA_TAG_RE = re.compile(r"\[[^\]]*\]") -# --------------------------------------------------------------------------- -# Text splitting -# --------------------------------------------------------------------------- - - def split_text_into_chunks(text: str, max_chars: int = DEFAULT_MAX_CHUNK_CHARS) -> List[str]: """Split *text* at natural boundaries into chunks of at most *max_chars*. @@ -174,11 +169,6 @@ def _safe_hard_cut(segment: str, max_chars: int) -> int: return cut -# --------------------------------------------------------------------------- -# Audio concatenation -# --------------------------------------------------------------------------- - - def concatenate_audio_chunks( chunks: List[np.ndarray], sample_rate: int, @@ -211,11 +201,6 @@ def concatenate_audio_chunks( return result -# --------------------------------------------------------------------------- -# Engine-agnostic chunked generation -# --------------------------------------------------------------------------- - - async def generate_chunked( backend, text: str, @@ -264,7 +249,11 @@ async def generate_chunked( if len(chunks) <= 1: # Short text โ€” single-shot fast path audio, sample_rate = await backend.generate( - text, voice_prompt, language, seed, instruct, + text, + voice_prompt, + language, + seed, + instruct, ) if trim_fn is not None: audio = trim_fn(audio, sample_rate) @@ -273,7 +262,9 @@ async def generate_chunked( # Long text โ€” chunked generation logger.info( "Splitting %d chars into %d chunks (max %d chars each)", - len(text), len(chunks), max_chunk_chars, + len(text), + len(chunks), + max_chunk_chars, ) audio_chunks: List[np.ndarray] = [] sample_rate: int | None = None @@ -281,7 +272,9 @@ async def generate_chunked( for i, chunk_text in enumerate(chunks): logger.info( "Generating chunk %d/%d (%d chars)", - i + 1, len(chunks), len(chunk_text), + i + 1, + len(chunks), + len(chunk_text), ) # Vary the seed per chunk to avoid correlated RNG artefacts, # but keep it deterministic so the same (text, seed) pair @@ -289,7 +282,11 @@ async def generate_chunked( chunk_seed = (seed + i) if seed is not None else None chunk_audio, chunk_sr = await backend.generate( - chunk_text, voice_prompt, language, chunk_seed, instruct, + chunk_text, + voice_prompt, + language, + chunk_seed, + instruct, ) if trim_fn is not None: chunk_audio = trim_fn(chunk_audio, chunk_sr) diff --git a/backend/utils/effects.py b/backend/utils/effects.py index 1824113e..afeefdde 100644 --- a/backend/utils/effects.py +++ b/backend/utils/effects.py @@ -35,10 +35,6 @@ from pedalboard import ( ) -# --------------------------------------------------------------------------- -# Effect registry: maps type names -> (pedalboard class, param definitions) -# --------------------------------------------------------------------------- - # Each param definition: (default, min, max, description) EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = { "chorus": { @@ -46,11 +42,17 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = { "label": "Chorus / Flanger", "description": "Modulated delay for flanging or chorus effects. Short centre_delay_ms (<10) gives flanger; longer gives chorus.", "params": { - "rate_hz": {"default": 1.0, "min": 0.01, "max": 20.0, "step": 0.01, "description": "LFO speed (Hz)"}, - "depth": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Modulation depth"}, - "feedback": {"default": 0.0, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"}, - "centre_delay_ms": {"default": 7.0, "min": 0.5, "max": 50.0, "step": 0.1, "description": "Centre delay (ms)"}, - "mix": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"}, + "rate_hz": {"default": 1.0, "min": 0.01, "max": 20.0, "step": 0.01, "description": "LFO speed (Hz)"}, + "depth": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Modulation depth"}, + "feedback": {"default": 0.0, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"}, + "centre_delay_ms": { + "default": 7.0, + "min": 0.5, + "max": 50.0, + "step": 0.1, + "description": "Centre delay (ms)", + }, + "mix": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"}, }, }, "reverb": { @@ -58,11 +60,11 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = { "label": "Reverb", "description": "Room reverb effect.", "params": { - "room_size": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Room size"}, - "damping": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "High frequency damping"}, - "wet_level": {"default": 0.33, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet level"}, - "dry_level": {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Dry level"}, - "width": {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Stereo width"}, + "room_size": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Room size"}, + "damping": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "High frequency damping"}, + "wet_level": {"default": 0.33, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet level"}, + "dry_level": {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Dry level"}, + "width": {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Stereo width"}, }, }, "delay": { @@ -70,9 +72,15 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = { "label": "Delay", "description": "Echo / delay line.", "params": { - "delay_seconds": {"default": 0.3, "min": 0.01, "max": 2.0, "step": 0.01, "description": "Delay time (seconds)"}, - "feedback": {"default": 0.3, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"}, - "mix": {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"}, + "delay_seconds": { + "default": 0.3, + "min": 0.01, + "max": 2.0, + "step": 0.01, + "description": "Delay time (seconds)", + }, + "feedback": {"default": 0.3, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"}, + "mix": {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"}, }, }, "compressor": { @@ -80,10 +88,16 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = { "label": "Compressor", "description": "Dynamic range compression for consistent loudness.", "params": { - "threshold_db": {"default": -20.0, "min": -60.0, "max": 0.0, "step": 0.5, "description": "Threshold (dB)"}, - "ratio": {"default": 4.0, "min": 1.0, "max": 20.0, "step": 0.1, "description": "Compression ratio"}, - "attack_ms": {"default": 10.0, "min": 0.1, "max": 100.0, "step": 0.1, "description": "Attack time (ms)"}, - "release_ms": {"default": 100.0, "min": 10.0, "max": 1000.0,"step": 1.0, "description": "Release time (ms)"}, + "threshold_db": {"default": -20.0, "min": -60.0, "max": 0.0, "step": 0.5, "description": "Threshold (dB)"}, + "ratio": {"default": 4.0, "min": 1.0, "max": 20.0, "step": 0.1, "description": "Compression ratio"}, + "attack_ms": {"default": 10.0, "min": 0.1, "max": 100.0, "step": 0.1, "description": "Attack time (ms)"}, + "release_ms": { + "default": 100.0, + "min": 10.0, + "max": 1000.0, + "step": 1.0, + "description": "Release time (ms)", + }, }, }, "gain": { @@ -99,7 +113,13 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = { "label": "High-Pass Filter", "description": "Removes frequencies below the cutoff.", "params": { - "cutoff_frequency_hz": {"default": 80.0, "min": 20.0, "max": 8000.0, "step": 1.0, "description": "Cutoff frequency (Hz)"}, + "cutoff_frequency_hz": { + "default": 80.0, + "min": 20.0, + "max": 8000.0, + "step": 1.0, + "description": "Cutoff frequency (Hz)", + }, }, }, "lowpass": { @@ -107,7 +127,13 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = { "label": "Low-Pass Filter", "description": "Removes frequencies above the cutoff.", "params": { - "cutoff_frequency_hz": {"default": 8000.0, "min": 200.0, "max": 20000.0, "step": 1.0, "description": "Cutoff frequency (Hz)"}, + "cutoff_frequency_hz": { + "default": 8000.0, + "min": 200.0, + "max": 20000.0, + "step": 1.0, + "description": "Cutoff frequency (Hz)", + }, }, }, "pitch_shift": { @@ -121,10 +147,6 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = { } -# --------------------------------------------------------------------------- -# Built-in presets -# --------------------------------------------------------------------------- - BUILTIN_PRESETS: Dict[str, Dict[str, Any]] = { "robotic": { "name": "Robotic", @@ -233,10 +255,6 @@ BUILTIN_PRESETS: Dict[str, Dict[str, Any]] = { } -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - def get_available_effects() -> List[Dict[str, Any]]: """Return the list of available effect types with their parameter definitions. @@ -244,15 +262,14 @@ def get_available_effects() -> List[Dict[str, Any]]: """ result = [] for effect_type, info in EFFECT_REGISTRY.items(): - result.append({ - "type": effect_type, - "label": info["label"], - "description": info["description"], - "params": { - name: {k: v for k, v in pdef.items()} - for name, pdef in info["params"].items() - }, - }) + result.append( + { + "type": effect_type, + "label": info["label"], + "description": info["description"], + "params": {name: {k: v for k, v in pdef.items()} for name, pdef in info["params"].items()}, + } + ) return result