diff --git a/app/src/components/ui/select.tsx b/app/src/components/ui/select.tsx index 7bdfe2b0..22e981d3 100644 --- a/app/src/components/ui/select.tsx +++ b/app/src/components/ui/select.tsx @@ -16,7 +16,7 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1', + 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:bg-muted disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1', className, )} {...props} diff --git a/backend/app.py b/backend/app.py index 69cee59b..4e29163e 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,9 +1,12 @@ """FastAPI application factory, middleware, and lifecycle events.""" import asyncio +import logging import os from pathlib import Path +logger = logging.getLogger(__name__) + # AMD GPU environment variables must be set before torch import if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"): os.environ["HSA_OVERRIDE_GFX_VERSION"] = "10.3.0" @@ -30,11 +33,9 @@ def safe_content_disposition(disposition_type: str, filename: str) -> str: Uses RFC 5987 ``filename*`` parameter so 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}"; filename*=UTF-8\'\'{utf8_name}' + return f"{disposition_type}; filename=\"{ascii_name}\"; filename*=UTF-8''{utf8_name}" def create_app() -> FastAPI: @@ -55,13 +56,13 @@ def create_app() -> FastAPI: def _configure_cors(application: FastAPI) -> None: """Set up CORS middleware with local-first defaults.""" 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", "") all_origins = default_origins + [o.strip() for o in env_origins.split(",") if o.strip()] @@ -96,9 +97,9 @@ def _register_lifecycle(application: FastAPI) -> None: @application.on_event("startup") async def startup_event(): - print("voicebox API starting up...") + logger.info("Voicebox server starting up...") database.init_db() - print(f"Database initialized at {database._db_path}") + logger.info("Database initialized at %s", database._db_path) init_queue() @@ -115,15 +116,15 @@ def _register_lifecycle(application: FastAPI) -> None: ) ) if result.rowcount > 0: - print(f"Marked {result.rowcount} stale generation(s) as failed") + logger.info("Marked %d stale generation(s) as failed", result.rowcount) db.commit() db.close() except Exception as e: - print(f"Warning: Could not clean up stale generations: {e}") + logger.warning("Could not clean up stale generations: %s", e) backend_type = get_backend_type() - print(f"Backend: {backend_type.upper()}") - print(f"GPU available: {_get_gpu_status()}") + logger.info("Backend: %s", backend_type.upper()) + logger.info("GPU available: %s", _get_gpu_status()) from .services.cuda import check_and_update_cuda_binary @@ -132,23 +133,23 @@ def _register_lifecycle(application: FastAPI) -> None: try: progress_manager = get_progress_manager() progress_manager._set_main_loop(asyncio.get_running_loop()) - print("Progress manager initialized with event loop") + logger.info("Progress manager initialized with event loop") except Exception as e: - print(f"Warning: Could not initialize progress manager event loop: {e}") + logger.warning("Could not initialize progress manager event loop: %s", e) 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}") + logger.info("HuggingFace cache directory: %s", cache_dir) except Exception as e: - print(f"Warning: Could not create HuggingFace cache directory: {e}") - print("Model downloads may fail. Please ensure the directory exists and has write permissions.") + logger.warning("Could not create HuggingFace cache directory: %s", e) + logger.warning("Model downloads may fail. Please ensure the directory exists and has write permissions.") @application.on_event("shutdown") async def shutdown_event(): - print("voicebox API shutting down...") + logger.info("Voicebox server shutting down...") tts.unload_tts_model() transcribe.unload_whisper_model() diff --git a/backend/backends/mlx_backend.py b/backend/backends/mlx_backend.py index 1ea7746f..e4a1ea97 100644 --- a/backend/backends/mlx_backend.py +++ b/backend/backends/mlx_backend.py @@ -4,13 +4,17 @@ MLX backend implementation for TTS and STT using mlx-audio. from typing import Optional, List, Tuple import asyncio +import logging import numpy as np import os from pathlib import Path +logger = logging.getLogger(__name__) + # PATCH: Import and apply offline patch BEFORE any huggingface_hub usage # This prevents mlx_audio from making network requests when models are cached from ..utils.hf_offline_patch import patch_huggingface_hub_offline, ensure_original_qwen_config_cached + patch_huggingface_hub_offline() ensure_original_qwen_config_cached() @@ -21,23 +25,23 @@ from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_pr class MLXTTSBackend: """MLX-based TTS backend using mlx-audio.""" - + def __init__(self, model_size: str = "1.7B"): self.model = None self.model_size = model_size self._current_model_size = None - + def is_loaded(self) -> bool: """Check if model is loaded.""" return self.model is not None - + def _get_model_path(self, model_size: str) -> str: """ Get the MLX model path. - + Args: model_size: Model size (1.7B or 0.6B) - + Returns: HuggingFace Hub model ID for MLX """ @@ -47,67 +51,68 @@ class MLXTTSBackend: # 0.6B not yet converted to MLX format "0.6B": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16", # Fallback to 1.7B } - + if model_size not in mlx_model_map: raise ValueError(f"Unknown model size: {model_size}") - + hf_model_id = mlx_model_map[model_size] - print(f"Will download MLX model from HuggingFace Hub: {hf_model_id}") - + logger.info("Will download MLX model from HuggingFace Hub: %s", hf_model_id) + return hf_model_id - + def _is_model_cached(self, model_size: str) -> bool: return is_model_cached( self._get_model_path(model_size), weight_extensions=(".safetensors", ".bin", ".npz"), ) - + async def load_model_async(self, model_size: Optional[str] = None): """ Lazy load the MLX TTS model. - + Args: model_size: Model size to load (1.7B or 0.6B) """ if model_size is None: model_size = self.model_size - + # If already loaded with correct size, return if self.model is not None and self._current_model_size == model_size: return - + # Unload existing model if different size requested if self.model is not None and self._current_model_size != model_size: self.unload_model() - + # Run blocking load in thread pool await asyncio.to_thread(self._load_model_sync, model_size) - + # Alias for compatibility load_model = load_model_async - + def _load_model_sync(self, model_size: str): """Synchronous model loading.""" model_path = self._get_model_path(model_size) model_name = f"qwen-tts-{model_size}" is_cached = self._is_model_cached(model_size) - + # Force offline mode when cached to avoid network requests original_hf_hub_offline = os.environ.get("HF_HUB_OFFLINE") if is_cached: os.environ["HF_HUB_OFFLINE"] = "1" - print(f"[PATCH] Model {model_size} is cached, forcing HF_HUB_OFFLINE=1 to avoid network requests") - + logger.info("[PATCH] Model %s is cached, forcing HF_HUB_OFFLINE=1 to avoid network requests", model_size) + try: with model_load_progress(model_name, is_cached): from mlx_audio.tts import load - print(f"Loading MLX TTS model {model_size}...") - + + logger.info("Loading MLX TTS model %s...", model_size) + try: self.model = load(model_path) except Exception as load_error: if is_cached and "offline" in str(load_error).lower(): - print(f"[PATCH] Offline load failed, trying with network: {load_error}") + logger.warning("[PATCH] Offline load failed, trying with network: %s", load_error) os.environ.pop("HF_HUB_OFFLINE", None) self.model = load(model_path) else: @@ -117,19 +122,19 @@ class MLXTTSBackend: os.environ["HF_HUB_OFFLINE"] = original_hf_hub_offline else: os.environ.pop("HF_HUB_OFFLINE", None) - + self._current_model_size = model_size self.model_size = model_size - print(f"MLX TTS model {model_size} loaded successfully") - + logger.info("MLX TTS model %s loaded successfully", model_size) + def unload_model(self): """Unload the model to free memory.""" if self.model is not None: del self.model self.model = None self._current_model_size = None - print("MLX TTS model unloaded") - + logger.info("MLX TTS model unloaded") + async def create_voice_prompt( self, audio_path: str, @@ -138,20 +143,20 @@ class MLXTTSBackend: ) -> Tuple[dict, bool]: """ Create voice prompt from reference audio. - + MLX backend stores voice prompt as a dict with audio path and text. The actual voice prompt processing happens during generation. - + Args: audio_path: Path to reference audio file reference_text: Transcript of reference audio use_cache: Whether to use cached prompt if available - + Returns: Tuple of (voice_prompt_dict, was_cached) """ await self.load_model_async(None) - + # Check cache if enabled if use_cache: cache_key = get_cache_key(audio_path, reference_text) @@ -165,25 +170,25 @@ class MLXTTSBackend: return cached_prompt, True else: # Cached file no longer exists, invalidate cache - print(f"Cached audio file not found: {cached_audio_path}, regenerating prompt") - + logger.warning("Cached audio file not found: %s, regenerating prompt", cached_audio_path) + # MLX voice prompt format - store audio path and text # The model will process this during generation voice_prompt_items = { "ref_audio": str(audio_path), "ref_text": reference_text, } - + # Cache if enabled if use_cache: cache_key = get_cache_key(audio_path, reference_text) cache_voice_prompt(cache_key, voice_prompt_items) - + return voice_prompt_items, False - + async def combine_voice_prompts(self, audio_paths, reference_texts): return await _combine_voice_prompts(audio_paths, reference_texts) - + async def generate( self, text: str, @@ -207,7 +212,7 @@ class MLXTTSBackend: """ await self.load_model_async(None) - print(f"Generating audio for text: {text}") + logger.info("Generating audio for text: %s", text) def _generate_sync(): """Run synchronous generation in thread pool.""" @@ -219,20 +224,21 @@ class MLXTTSBackend: # Set seed if provided (MLX uses numpy random) if seed is not None: import mlx.core as mx + np.random.seed(seed) mx.random.seed(seed) - + # Extract voice prompt info ref_audio = voice_prompt.get("ref_audio") or voice_prompt.get("ref_audio_path") ref_text = voice_prompt.get("ref_text", "") - + # Validate that the audio file exists if ref_audio and not Path(ref_audio).exists(): - print(f"Warning: Audio file not found: {ref_audio}") - print("This may be due to a cached voice prompt referencing a deleted temp file.") - print("Regenerating without voice prompt.") + logger.warning("Audio file not found: %s", ref_audio) + logger.warning("This may be due to a cached voice prompt referencing a deleted temp file.") + logger.warning("Regenerating without voice prompt.") ref_audio = None - + # Check if model supports voice cloning via generate method # MLX API may support ref_audio parameter directly try: @@ -240,6 +246,7 @@ class MLXTTSBackend: if ref_audio: # Check if generate accepts ref_audio parameter import inspect + sig = inspect.signature(self.model.generate) if "ref_audio" in sig.parameters: # Generate with voice cloning @@ -258,18 +265,18 @@ class MLXTTSBackend: sample_rate = result.sample_rate except Exception as e: # If voice cloning fails, try without it - print(f"Warning: Voice cloning failed, generating without voice prompt: {e}") + logger.warning("Voice cloning failed, generating without voice prompt: %s", e) for result in self.model.generate(text, lang_code=lang): audio_chunks.append(np.array(result.audio)) sample_rate = result.sample_rate - + # Concatenate all chunks if audio_chunks: audio = np.concatenate([np.asarray(chunk, dtype=np.float32) for chunk in audio_chunks]) else: # Fallback: empty audio audio = np.array([], dtype=np.float32) - + return audio, sample_rate # Run blocking inference in thread pool @@ -284,55 +291,56 @@ class MLXSTTBackend: def __init__(self, model_size: str = "base"): self.model = None self.model_size = model_size - + def is_loaded(self) -> bool: """Check if model is loaded.""" return self.model is not None - + def _is_model_cached(self, model_size: str) -> bool: hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}") return is_model_cached(hf_repo, weight_extensions=(".safetensors", ".bin", ".npz")) - + async def load_model_async(self, model_size: Optional[str] = None): """ Lazy load the MLX Whisper model. - + Args: model_size: Model size (tiny, base, small, medium, large) """ if model_size is None: model_size = self.model_size - + if self.model is not None and self.model_size == model_size: return - + # Run blocking load in thread pool await asyncio.to_thread(self._load_model_sync, model_size) - + # Alias for compatibility load_model = load_model_async - + def _load_model_sync(self, model_size: str): """Synchronous model loading.""" progress_model_name = f"whisper-{model_size}" is_cached = self._is_model_cached(model_size) - + with model_load_progress(progress_model_name, is_cached): from mlx_audio.stt import load + model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}") - print(f"Loading MLX Whisper model {model_size}...") + logger.info("Loading MLX Whisper model %s...", model_size) self.model = load(model_name) - + self.model_size = model_size - print(f"MLX Whisper model {model_size} loaded successfully") - + logger.info("MLX Whisper model %s loaded successfully", model_size) + def unload_model(self): """Unload the model to free memory.""" if self.model is not None: del self.model self.model = None - print("MLX Whisper model unloaded") - + logger.info("MLX Whisper model unloaded") + async def transcribe( self, audio_path: str, diff --git a/backend/backends/pytorch_backend.py b/backend/backends/pytorch_backend.py index 809f6984..8f4a7a58 100644 --- a/backend/backends/pytorch_backend.py +++ b/backend/backends/pytorch_backend.py @@ -4,39 +4,47 @@ PyTorch backend implementation for TTS and STT. from typing import Optional, List, Tuple import asyncio +import logging import torch import numpy as np +logger = logging.getLogger(__name__) + from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS -from .base import is_model_cached, get_torch_device, combine_voice_prompts as _combine_voice_prompts, model_load_progress +from .base import ( + is_model_cached, + get_torch_device, + combine_voice_prompts as _combine_voice_prompts, + model_load_progress, +) from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt from ..utils.audio import load_audio class PyTorchTTSBackend: """PyTorch-based TTS backend using Qwen3-TTS.""" - + def __init__(self, model_size: str = "1.7B"): self.model = None self.model_size = model_size self.device = self._get_device() self._current_model_size = None - + def _get_device(self) -> str: """Get the best available device.""" return get_torch_device(allow_xpu=True, allow_directml=True) - + def is_loaded(self) -> bool: """Check if model is loaded.""" return self.model is not None - + def _get_model_path(self, model_size: str) -> str: """ Get the HuggingFace Hub model ID. - + Args: model_size: Model size (1.7B or 0.6B) - + Returns: HuggingFace Hub model ID """ @@ -44,39 +52,39 @@ class PyTorchTTSBackend: "1.7B": "Qwen/Qwen3-TTS-12Hz-1.7B-Base", "0.6B": "Qwen/Qwen3-TTS-12Hz-0.6B-Base", } - + if model_size not in hf_model_map: raise ValueError(f"Unknown model size: {model_size}") - + return hf_model_map[model_size] - + def _is_model_cached(self, model_size: str) -> bool: return is_model_cached(self._get_model_path(model_size)) - + async def load_model_async(self, model_size: Optional[str] = None): """ Lazy load the TTS model with automatic downloading from HuggingFace Hub. - + Args: model_size: Model size to load (1.7B or 0.6B) """ if model_size is None: model_size = self.model_size - + # If already loaded with correct size, return if self.model is not None and self._current_model_size == model_size: return - + # Unload existing model if different size requested if self.model is not None and self._current_model_size != model_size: self.unload_model() - + # Run blocking load in thread pool await asyncio.to_thread(self._load_model_sync, model_size) - + # Alias for compatibility load_model = load_model_async - + def _load_model_sync(self, model_size: str): """Synchronous model loading.""" model_name = f"qwen-tts-{model_size}" @@ -84,8 +92,9 @@ class PyTorchTTSBackend: with model_load_progress(model_name, is_cached): from qwen_tts import Qwen3TTSModel + model_path = self._get_model_path(model_size) - print(f"Loading TTS model {model_size} on {self.device}...") + logger.info("Loading TTS model %s on %s...", model_size, self.device) if self.device == "cpu": self.model = Qwen3TTSModel.from_pretrained( @@ -102,20 +111,20 @@ class PyTorchTTSBackend: self._current_model_size = model_size self.model_size = model_size - print(f"TTS model {model_size} loaded successfully") - + logger.info("TTS model %s loaded successfully", model_size) + def unload_model(self): """Unload the model to free memory.""" if self.model is not None: del self.model self.model = None self._current_model_size = None - + if torch.cuda.is_available(): torch.cuda.empty_cache() - - print("TTS model unloaded") - + + logger.info("TTS model unloaded") + async def create_voice_prompt( self, audio_path: str, @@ -124,17 +133,17 @@ class PyTorchTTSBackend: ) -> Tuple[dict, bool]: """ Create voice prompt from reference audio. - + Args: audio_path: Path to reference audio file reference_text: Transcript of reference audio use_cache: Whether to use cached prompt if available - + Returns: Tuple of (voice_prompt_dict, was_cached) """ await self.load_model_async(None) - + # Check cache if enabled if use_cache: cache_key = get_cache_key(audio_path, reference_text) @@ -150,7 +159,7 @@ class PyTorchTTSBackend: # Legacy cache format - convert to dict # This shouldn't happen in practice, but handle it return {"prompt": cached_prompt}, True - + def _create_prompt_sync(): """Run synchronous voice prompt creation in thread pool.""" return self.model.create_voice_clone_prompt( @@ -158,24 +167,24 @@ class PyTorchTTSBackend: ref_text=reference_text, x_vector_only_mode=False, ) - + # Run blocking operation in thread pool voice_prompt_items = await asyncio.to_thread(_create_prompt_sync) - + # Cache if enabled if use_cache: cache_key = get_cache_key(audio_path, reference_text) cache_voice_prompt(cache_key, voice_prompt_items) - + return voice_prompt_items, False - + async def combine_voice_prompts( self, audio_paths: List[str], reference_texts: List[str], ) -> Tuple[np.ndarray, str]: return await _combine_voice_prompts(audio_paths, reference_texts) - + async def generate( self, text: str, @@ -231,15 +240,15 @@ class PyTorchSTTBackend: self.processor = None self.model_size = model_size self.device = self._get_device() - + def _get_device(self) -> str: """Get the best available device.""" return get_torch_device(allow_xpu=True, allow_directml=True) - + def is_loaded(self) -> bool: """Check if model is loaded.""" return self.model is not None - + def _is_model_cached(self, model_size: str) -> bool: hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}") return is_model_cached(hf_repo) @@ -258,10 +267,10 @@ class PyTorchSTTBackend: return await asyncio.to_thread(self._load_model_sync, model_size) - + # Alias for compatibility load_model = load_model_async - + def _load_model_sync(self, model_size: str): """Synchronous model loading.""" progress_model_name = f"whisper-{model_size}" @@ -269,16 +278,17 @@ class PyTorchSTTBackend: with model_load_progress(progress_model_name, is_cached): from transformers import WhisperProcessor, WhisperForConditionalGeneration + model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}") - print(f"Loading Whisper model {model_size} on {self.device}...") + logger.info("Loading Whisper model %s on %s...", model_size, self.device) self.processor = WhisperProcessor.from_pretrained(model_name) self.model = WhisperForConditionalGeneration.from_pretrained(model_name) self.model.to(self.device) self.model_size = model_size - print(f"Whisper model {model_size} loaded successfully") - + logger.info("Whisper model %s loaded successfully", model_size) + def unload_model(self): """Unload the model to free memory.""" if self.model is not None: @@ -286,12 +296,12 @@ class PyTorchSTTBackend: del self.processor self.model = None self.processor = None - + if torch.cuda.is_available(): torch.cuda.empty_cache() - - print("Whisper model unloaded") - + + logger.info("Whisper model unloaded") + async def transcribe( self, audio_path: str, @@ -299,21 +309,21 @@ class PyTorchSTTBackend: ) -> str: """ Transcribe audio to text. - + Args: audio_path: Path to audio file language: Optional language hint (en or zh) - + Returns: Transcribed text """ await self.load_model_async(None) - + def _transcribe_sync(): """Run synchronous transcription in thread pool.""" # Load audio audio, sr = load_audio(audio_path, sample_rate=16000) - + # Process audio inputs = self.processor( audio, @@ -321,7 +331,7 @@ class PyTorchSTTBackend: return_tensors="pt", ) inputs = inputs.to(self.device) - + # Generate transcription # If language is provided, force it; otherwise let Whisper auto-detect generate_kwargs = {} @@ -331,20 +341,20 @@ class PyTorchSTTBackend: task="transcribe", ) generate_kwargs["forced_decoder_ids"] = forced_decoder_ids - + with torch.no_grad(): predicted_ids = self.model.generate( inputs["input_features"], **generate_kwargs, ) - + # Decode transcription = self.processor.batch_decode( predicted_ids, skip_special_tokens=True, )[0] - + return transcription.strip() - + # Run blocking transcription in thread pool return await asyncio.to_thread(_transcribe_sync) diff --git a/backend/build_binary.py b/backend/build_binary.py index 005bbe25..b4b1bb7c 100644 --- a/backend/build_binary.py +++ b/backend/build_binary.py @@ -8,11 +8,14 @@ Usage: import PyInstaller.__main__ import argparse +import logging import os import platform import sys from pathlib import Path +logger = logging.getLogger(__name__) + def is_apple_silicon(): """Check if running on Apple Silicon.""" @@ -28,155 +31,246 @@ def build_server(cuda=False): """ backend_dir = Path(__file__).parent - binary_name = 'voicebox-server-cuda' if cuda else 'voicebox-server' + binary_name = "voicebox-server-cuda" if cuda else "voicebox-server" # PyInstaller arguments args = [ - 'server.py', # Use server.py as entry point instead of main.py - '--onefile', - '--name', binary_name, + "server.py", # Use server.py as entry point instead of main.py + "--onefile", + "--name", + binary_name, ] # Hide console window on Windows only. On macOS/Linux the sidecar needs # stdout/stderr for Tauri to capture logs. if platform.system() == "Windows": - args.append('--noconsole') + args.append("--noconsole") # Add local qwen_tts path if specified (for editable installs) - qwen_tts_path = os.getenv('QWEN_TTS_PATH') + qwen_tts_path = os.getenv("QWEN_TTS_PATH") if qwen_tts_path and Path(qwen_tts_path).exists(): - args.extend(['--paths', str(qwen_tts_path)]) + args.extend(["--paths", str(qwen_tts_path)]) print(f"Using local qwen_tts source from: {qwen_tts_path}") # Add common hidden imports - args.extend([ - '--hidden-import', 'backend', - '--hidden-import', 'backend.main', - '--hidden-import', 'backend.config', - '--hidden-import', 'backend.database', - '--hidden-import', 'backend.models', - '--hidden-import', 'backend.services.profiles', - '--hidden-import', 'backend.services.history', - '--hidden-import', 'backend.services.tts', - '--hidden-import', 'backend.services.transcribe', - '--hidden-import', 'backend.utils.platform_detect', - '--hidden-import', 'backend.backends', - '--hidden-import', 'backend.backends.pytorch_backend', - '--hidden-import', 'backend.utils.audio', - '--hidden-import', 'backend.utils.cache', - '--hidden-import', 'backend.utils.progress', - '--hidden-import', 'backend.utils.hf_progress', - - '--hidden-import', 'backend.services.cuda', - '--hidden-import', 'backend.services.effects', - '--hidden-import', 'backend.utils.effects', - '--hidden-import', 'backend.services.versions', - '--hidden-import', 'pedalboard', - '--hidden-import', 'chatterbox', - '--hidden-import', 'chatterbox.tts_turbo', - '--hidden-import', 'chatterbox.mtl_tts', - '--hidden-import', 'backend.backends.chatterbox_backend', - '--hidden-import', 'backend.backends.chatterbox_turbo_backend', - '--hidden-import', 'backend.backends.luxtts_backend', - '--hidden-import', 'zipvoice', - '--hidden-import', 'zipvoice.luxvoice', - '--collect-all', 'zipvoice', - '--collect-all', 'linacodec', - '--hidden-import', 'torch', - '--hidden-import', 'transformers', - '--hidden-import', 'fastapi', - '--hidden-import', 'uvicorn', - '--hidden-import', 'sqlalchemy', - '--hidden-import', 'librosa', - '--hidden-import', 'soundfile', - '--hidden-import', 'qwen_tts', - '--hidden-import', 'qwen_tts.inference', - '--hidden-import', 'qwen_tts.inference.qwen3_tts_model', - '--hidden-import', 'qwen_tts.inference.qwen3_tts_tokenizer', - '--hidden-import', 'qwen_tts.core', - '--hidden-import', 'qwen_tts.cli', - '--copy-metadata', 'qwen-tts', - '--copy-metadata', 'requests', - '--copy-metadata', 'transformers', - '--copy-metadata', 'huggingface-hub', - '--copy-metadata', 'tokenizers', - '--copy-metadata', 'safetensors', - '--copy-metadata', 'tqdm', - '--hidden-import', 'requests', - '--collect-submodules', 'qwen_tts', - '--collect-data', 'qwen_tts', - # Fix for pkg_resources and jaraco namespace packages - '--hidden-import', 'pkg_resources.extern', - '--collect-submodules', 'jaraco', - # inflect uses typeguard @typechecked which calls inspect.getsource() - # at import time — needs .py source files, not just .pyc bytecode - '--collect-all', 'inflect', - # perth ships pretrained watermark model files (hparams.yaml, .pth.tar) - # in perth/perth_net/pretrained/ — needed by chatterbox at runtime - '--collect-all', 'perth', - # piper_phonemize ships espeak-ng-data/ (phoneme tables, language dicts) - # needed by LuxTTS for text-to-phoneme conversion - '--collect-all', 'piper_phonemize', - ]) + args.extend( + [ + "--hidden-import", + "backend", + "--hidden-import", + "backend.main", + "--hidden-import", + "backend.config", + "--hidden-import", + "backend.database", + "--hidden-import", + "backend.models", + "--hidden-import", + "backend.services.profiles", + "--hidden-import", + "backend.services.history", + "--hidden-import", + "backend.services.tts", + "--hidden-import", + "backend.services.transcribe", + "--hidden-import", + "backend.utils.platform_detect", + "--hidden-import", + "backend.backends", + "--hidden-import", + "backend.backends.pytorch_backend", + "--hidden-import", + "backend.utils.audio", + "--hidden-import", + "backend.utils.cache", + "--hidden-import", + "backend.utils.progress", + "--hidden-import", + "backend.utils.hf_progress", + "--hidden-import", + "backend.services.cuda", + "--hidden-import", + "backend.services.effects", + "--hidden-import", + "backend.utils.effects", + "--hidden-import", + "backend.services.versions", + "--hidden-import", + "pedalboard", + "--hidden-import", + "chatterbox", + "--hidden-import", + "chatterbox.tts_turbo", + "--hidden-import", + "chatterbox.mtl_tts", + "--hidden-import", + "backend.backends.chatterbox_backend", + "--hidden-import", + "backend.backends.chatterbox_turbo_backend", + "--hidden-import", + "backend.backends.luxtts_backend", + "--hidden-import", + "zipvoice", + "--hidden-import", + "zipvoice.luxvoice", + "--collect-all", + "zipvoice", + "--collect-all", + "linacodec", + "--hidden-import", + "torch", + "--hidden-import", + "transformers", + "--hidden-import", + "fastapi", + "--hidden-import", + "uvicorn", + "--hidden-import", + "sqlalchemy", + "--hidden-import", + "librosa", + "--hidden-import", + "soundfile", + "--hidden-import", + "qwen_tts", + "--hidden-import", + "qwen_tts.inference", + "--hidden-import", + "qwen_tts.inference.qwen3_tts_model", + "--hidden-import", + "qwen_tts.inference.qwen3_tts_tokenizer", + "--hidden-import", + "qwen_tts.core", + "--hidden-import", + "qwen_tts.cli", + "--copy-metadata", + "qwen-tts", + "--copy-metadata", + "requests", + "--copy-metadata", + "transformers", + "--copy-metadata", + "huggingface-hub", + "--copy-metadata", + "tokenizers", + "--copy-metadata", + "safetensors", + "--copy-metadata", + "tqdm", + "--hidden-import", + "requests", + "--collect-submodules", + "qwen_tts", + "--collect-data", + "qwen_tts", + # Fix for pkg_resources and jaraco namespace packages + "--hidden-import", + "pkg_resources.extern", + "--collect-submodules", + "jaraco", + # inflect uses typeguard @typechecked which calls inspect.getsource() + # at import time — needs .py source files, not just .pyc bytecode + "--collect-all", + "inflect", + # perth ships pretrained watermark model files (hparams.yaml, .pth.tar) + # in perth/perth_net/pretrained/ — needed by chatterbox at runtime + "--collect-all", + "perth", + # piper_phonemize ships espeak-ng-data/ (phoneme tables, language dicts) + # needed by LuxTTS for text-to-phoneme conversion + "--collect-all", + "piper_phonemize", + ] + ) # Add CUDA-specific hidden imports if cuda: print("Building with CUDA support") - args.extend([ - '--hidden-import', 'torch.cuda', - '--hidden-import', 'torch.backends.cudnn', - ]) + args.extend( + [ + "--hidden-import", + "torch.cuda", + "--hidden-import", + "torch.backends.cudnn", + ] + ) else: # Exclude NVIDIA CUDA packages from CPU-only builds to keep binary small. # When building from a venv with CUDA torch installed, PyInstaller would # bundle ~3GB of NVIDIA shared libraries. We exclude both the Python # modules and the binary DLLs. nvidia_packages = [ - 'nvidia', 'nvidia.cublas', 'nvidia.cuda_cupti', 'nvidia.cuda_nvrtc', - 'nvidia.cuda_runtime', 'nvidia.cudnn', 'nvidia.cufft', 'nvidia.curand', - 'nvidia.cusolver', 'nvidia.cusparse', 'nvidia.nccl', 'nvidia.nvjitlink', - 'nvidia.nvtx', + "nvidia", + "nvidia.cublas", + "nvidia.cuda_cupti", + "nvidia.cuda_nvrtc", + "nvidia.cuda_runtime", + "nvidia.cudnn", + "nvidia.cufft", + "nvidia.curand", + "nvidia.cusolver", + "nvidia.cusparse", + "nvidia.nccl", + "nvidia.nvjitlink", + "nvidia.nvtx", ] for pkg in nvidia_packages: - args.extend(['--exclude-module', pkg]) + args.extend(["--exclude-module", pkg]) # Add MLX-specific imports if building on Apple Silicon (never for CUDA builds) if is_apple_silicon() and not cuda: print("Building for Apple Silicon - including MLX dependencies") - args.extend([ - '--hidden-import', 'backend.backends.mlx_backend', - '--hidden-import', 'mlx', - '--hidden-import', 'mlx.core', - '--hidden-import', 'mlx.nn', - '--hidden-import', 'mlx_audio', - '--hidden-import', 'mlx_audio.tts', - '--hidden-import', 'mlx_audio.stt', - '--collect-submodules', 'mlx', - '--collect-submodules', 'mlx_audio', - # Use --collect-all so PyInstaller bundles both data files AND - # native shared libraries (.dylib, .metallib) for MLX. - # Previously only --collect-data was used, which caused MLX to - # raise OSError at runtime inside the bundled binary because - # the Metal shader libraries were missing. - '--collect-all', 'mlx', - '--collect-all', 'mlx_audio', - ]) + args.extend( + [ + "--hidden-import", + "backend.backends.mlx_backend", + "--hidden-import", + "mlx", + "--hidden-import", + "mlx.core", + "--hidden-import", + "mlx.nn", + "--hidden-import", + "mlx_audio", + "--hidden-import", + "mlx_audio.tts", + "--hidden-import", + "mlx_audio.stt", + "--collect-submodules", + "mlx", + "--collect-submodules", + "mlx_audio", + # Use --collect-all so PyInstaller bundles both data files AND + # native shared libraries (.dylib, .metallib) for MLX. + # Previously only --collect-data was used, which caused MLX to + # raise OSError at runtime inside the bundled binary because + # the Metal shader libraries were missing. + "--collect-all", + "mlx", + "--collect-all", + "mlx_audio", + ] + ) elif not cuda: print("Building for non-Apple Silicon platform - PyTorch only") - dist_dir = str(backend_dir / 'dist') - build_dir = str(backend_dir / 'build') + dist_dir = str(backend_dir / "dist") + build_dir = str(backend_dir / "build") - args.extend([ - '--distpath', dist_dir, - '--workpath', build_dir, - '--noconfirm', - '--clean', - ]) + args.extend( + [ + "--distpath", + dist_dir, + "--workpath", + build_dir, + "--noconfirm", + "--clean", + ] + ) # Change to backend directory os.chdir(backend_dir) - + # For CPU builds on Windows, ensure we're using CPU-only torch. # If CUDA torch is installed (local dev), swap to CPU torch before building, # then restore CUDA torch after. This prevents PyInstaller from bundling @@ -184,17 +278,28 @@ def build_server(cuda=False): restore_cuda = False if not cuda and platform.system() == "Windows": import subprocess + result = subprocess.run( - [sys.executable, "-c", "import torch; print(torch.version.cuda or '')"], - capture_output=True, text=True + [sys.executable, "-c", "import torch; print(torch.version.cuda or '')"], capture_output=True, text=True ) has_cuda_torch = bool(result.stdout.strip()) if has_cuda_torch: print("CUDA torch detected — installing CPU torch for CPU build...") subprocess.run( - [sys.executable, "-m", "pip", "install", "torch", "torchvision", "torchaudio", - "--index-url", "https://download.pytorch.org/whl/cpu", "--force-reinstall", "-q"], - check=True + [ + sys.executable, + "-m", + "pip", + "install", + "torch", + "torchvision", + "torchaudio", + "--index-url", + "https://download.pytorch.org/whl/cpu", + "--force-reinstall", + "-q", + ], + check=True, ) restore_cuda = True @@ -206,55 +311,77 @@ def build_server(cuda=False): if restore_cuda: print("Restoring CUDA torch...") import subprocess + subprocess.run( - [sys.executable, "-m", "pip", "install", "torch", "torchvision", "torchaudio", - "--index-url", "https://download.pytorch.org/whl/cu126", "--force-reinstall", "-q"], - check=True + [ + sys.executable, + "-m", + "pip", + "install", + "torch", + "torchvision", + "torchaudio", + "--index-url", + "https://download.pytorch.org/whl/cu126", + "--force-reinstall", + "-q", + ], + check=True, ) - + print(f"Binary built in {backend_dir / 'dist' / binary_name}") def _get_cuda_dll_excludes(): """Get list of CUDA DLL filenames to exclude from CPU builds. - + When building locally with CUDA torch installed, PyInstaller bundles ~3GB of CUDA DLLs from torch/lib/. Returns a list of DLL filenames to exclude. """ try: import torch - torch_lib = Path(torch.__file__).parent / 'lib' + + torch_lib = Path(torch.__file__).parent / "lib" except ImportError: return [] - + cuda_prefixes = ( - 'torch_cuda', 'cublas', 'cublasLt', 'cudnn', 'cusparse', 'cufft', - 'cusolver', 'cusolverMg', 'curand', 'nvrtc', 'nvJitLink', 'nccl', - 'nvperf', 'nvrtc-builtins', + "torch_cuda", + "cublas", + "cublasLt", + "cudnn", + "cusparse", + "cufft", + "cusolver", + "cusolverMg", + "curand", + "nvrtc", + "nvJitLink", + "nccl", + "nvperf", + "nvrtc-builtins", ) - + exclude_dlls = [] if torch_lib.exists(): for f in torch_lib.iterdir(): - if f.suffix == '.dll' and any(f.name.startswith(p) for p in cuda_prefixes): + if f.suffix == ".dll" and any(f.name.startswith(p) for p in cuda_prefixes): exclude_dlls.append(f.name) - + if exclude_dlls: - total_mb = sum( - (torch_lib / dll).stat().st_size - for dll in exclude_dlls - if (torch_lib / dll).exists() - ) / 1024 / 1024 + total_mb = ( + sum((torch_lib / dll).stat().st_size for dll in exclude_dlls if (torch_lib / dll).exists()) / 1024 / 1024 + ) print(f"CPU build: will exclude {len(exclude_dlls)} CUDA DLLs ({total_mb:.0f} MB)") - + return exclude_dlls -if __name__ == '__main__': +if __name__ == "__main__": parser = argparse.ArgumentParser(description="Build voicebox-server binary") parser.add_argument( - '--cuda', - action='store_true', + "--cuda", + action="store_true", help="Build CUDA-enabled binary (voicebox-server-cuda)", ) cli_args = parser.parse_args() diff --git a/backend/services/generation.py b/backend/services/generation.py index ff86a0de..d8d5214d 100644 --- a/backend/services/generation.py +++ b/backend/services/generation.py @@ -235,8 +235,11 @@ def _save_regenerate( audio_path = config.get_generations_dir() / f"{generation_id}_{suffix}.wav" save_audio(audio, str(audio_path), sample_rate) - existing = versions_mod.list_versions(generation_id, db) - label = f"take-{len(existing) + 1}" + # Count via DB query rather than list length to avoid TOCTOU race + from ..database import GenerationVersion as DBGenerationVersion + + count = db.query(DBGenerationVersion).filter_by(generation_id=generation_id).count() + label = f"take-{count + 1}" versions_mod.create_version( generation_id=generation_id,