diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a954046..45d898e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -148,9 +148,10 @@ Voicebox uses a modular provider system to support different inference backends. **Hybrid Provider:** - `pytorch-cpu` — Can be bundled OR downloaded depending on platform - - **Bundled** with macOS Intel builds (`.dmg` for x64) - - Configured in `.github/workflows/release.yml` with `backend: "pytorch"` - - **Downloaded** on first use for Windows/Linux builds (~300MB) + - **Bundled** with Windows and macOS Intel builds + - macOS Intel: `.dmg` for x64 with `backend: "pytorch"` + - Windows: `.exe` installer with PyTorch CPU included + - **Downloaded** on first use for Linux builds (~300MB) - Falls back to bundled version if external binary not found **External-Only Providers:** diff --git a/README.md b/README.md index 6b4cb0a0..74672d6e 100644 --- a/README.md +++ b/README.md @@ -205,14 +205,14 @@ Voicebox uses a modular provider system to support different inference backends: - **`pytorch-cpu`** — Universal CPU provider (bundled or downloaded) - - Bundled with macOS Intel builds - - Downloaded on first use for Windows/Linux (~300MB) + - Bundled with Windows and macOS Intel builds + - Downloaded on first use for Linux (~300MB) - **`pytorch-cuda`** — Optional NVIDIA GPU-accelerated provider - Windows/Linux only (~2.4GB) - 4-5x faster inference on CUDA-capable GPUs -macOS builds work out of the box with bundled providers. Windows and Linux users download a provider on first launch. The app automatically detects your hardware and recommends the best option. All downloadable providers are distributed via Cloudflare R2 for fast, global delivery. +macOS and Windows builds work out of the box with bundled providers. Linux users download a provider on first launch. The app automatically detects your hardware and recommends the best option. All downloadable providers are distributed via Cloudflare R2 for fast, global delivery. --- diff --git a/backend/backends/__init__.py b/backend/backends/__init__.py index f7c47ba9..5f0c9b7e 100644 --- a/backend/backends/__init__.py +++ b/backend/backends/__init__.py @@ -118,22 +118,37 @@ _stt_backend: Optional[STTBackend] = None def get_tts_backend() -> TTSBackend: """ Get or create TTS backend instance based on platform. - + Returns: TTS backend instance (MLX or PyTorch) + + Raises: + ImportError: If required dependencies (mlx or torch) are not available """ global _tts_backend - + if _tts_backend is None: backend_type = get_backend_type() - + if backend_type == "mlx": - from .mlx_backend import MLXTTSBackend - _tts_backend = MLXTTSBackend() + try: + from .mlx_backend import MLXTTSBackend + _tts_backend = MLXTTSBackend() + except ImportError as e: + raise ImportError( + f"MLX backend dependencies not available. " + f"Please install mlx and mlx_audio or download a provider. Error: {e}" + ) else: - from .pytorch_backend import PyTorchTTSBackend - _tts_backend = PyTorchTTSBackend() - + try: + from .pytorch_backend import PyTorchTTSBackend + _tts_backend = PyTorchTTSBackend() + except ImportError as e: + raise ImportError( + f"PyTorch backend dependencies not available. " + f"Please download a TTS provider (pytorch-cpu or pytorch-cuda) from the Downloads page. Error: {e}" + ) + return _tts_backend diff --git a/backend/build_binary.py b/backend/build_binary.py index fb65863e..3d695fdb 100644 --- a/backend/build_binary.py +++ b/backend/build_binary.py @@ -59,9 +59,16 @@ def build_server(): # Fix for pkg_resources and jaraco namespace packages '--hidden-import', 'pkg_resources.extern', '--collect-submodules', 'jaraco', + # Asyncio and threading support for PyInstaller + '--hidden-import', 'asyncio', + '--hidden-import', 'asyncio.subprocess', + '--hidden-import', 'concurrent.futures', + '--hidden-import', 'concurrent.futures.thread', ]) # Platform-specific TTS backend handling + system = platform.system() + if is_apple_silicon(): print("Building for Apple Silicon - including MLX dependencies (bundled)") args.extend([ @@ -79,13 +86,30 @@ def build_server(): '--collect-data', 'mlx', '--collect-data', 'mlx_audio', ]) - else: - print("Building for Windows/Linux - excluding PyTorch/Qwen-TTS (providers downloaded separately)") - # Note: PyTorch and Qwen-TTS are NOT included - users will download providers separately - # Only include backend abstraction (no actual TTS implementation) + elif system == "Windows" or (system == "Darwin" and not is_apple_silicon()): + # Windows and Intel macOS: Bundle PyTorch CPU provider + print(f"Building for {system} - including PyTorch CPU provider (bundled)") args.extend([ '--hidden-import', 'backend.backends', - '--hidden-import', 'backend.backends.pytorch_backend', # Keep for reference, but won't work without PyTorch + '--hidden-import', 'backend.backends.pytorch_backend', + '--hidden-import', 'torch', + '--hidden-import', 'transformers', + '--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', + '--collect-submodules', 'qwen_tts', + '--collect-data', 'qwen_tts', + ]) + else: + # Linux: No bundled provider - users download providers separately + print("Building for Linux - no bundled provider (users download separately)") + args.extend([ + '--hidden-import', 'backend.backends', + '--hidden-import', 'backend.backends.pytorch_backend', ]) args.extend([ diff --git a/backend/main.py b/backend/main.py index 31089227..f0c774a5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,7 +14,6 @@ from datetime import datetime import asyncio import uvicorn import argparse -import torch import tempfile import io from pathlib import Path @@ -23,6 +22,14 @@ import asyncio import signal import os +# Optional torch import - not available on all platforms (e.g. Windows/Linux without bundled provider) +try: + import torch + TORCH_AVAILABLE = True +except ImportError: + torch = None # type: ignore + TORCH_AVAILABLE = False + from . import database, models, profiles, history, tts, transcribe, config, export_import, channels, stories, __version__ from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile from .utils.progress import get_progress_manager @@ -72,20 +79,32 @@ async def shutdown(): @app.get("/health", response_model=models.HealthResponse) async def health(): """Health check endpoint.""" - from huggingface_hub import hf_hub_download, constants as hf_constants + from huggingface_hub import constants as hf_constants from pathlib import Path - import os - tts_model = await tts.get_tts_model_async() + # Try to get TTS model provider, but it may not be available if dependencies aren't installed + tts_model = None + try: + tts_model = await tts.get_tts_model_async() + except ImportError as e: + # Provider dependencies not available (e.g., PyTorch not bundled on this platform) + # This is expected on Windows/Linux builds without a bundled provider + print(f"Provider not available: {e}") + backend_type = get_backend_type() # Check for GPU availability (CUDA or MPS) - has_cuda = torch.cuda.is_available() - has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available() + # PyTorch might not be available if no provider is bundled + has_cuda = False + has_mps = False + if TORCH_AVAILABLE and torch is not None: + has_cuda = torch.cuda.is_available() + has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available() + gpu_available = has_cuda or has_mps gpu_type = None - if has_cuda: + if has_cuda and torch is not None: gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})" elif has_mps: gpu_type = "MPS (Apple Silicon)" @@ -93,26 +112,27 @@ async def health(): gpu_type = "Metal (Apple Silicon via MLX)" vram_used = None - if has_cuda: + if has_cuda and torch is not None: vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB - + # Check if model is loaded - use the same logic as model status endpoint model_loaded = False model_size = None - try: - # Use the same check as model status endpoint - if tts_model.is_loaded(): - model_loaded = True - # Get the actual loaded model size - # Check _current_model_size first (more reliable for actually loaded models) - model_size = getattr(tts_model, '_current_model_size', None) - if not model_size: - # Fallback to model_size attribute (which should be set when model loads) - model_size = getattr(tts_model, 'model_size', None) - except Exception: - # If there's an error checking, assume not loaded - model_loaded = False - model_size = None + if tts_model is not None: + try: + # Use the same check as model status endpoint + if tts_model.is_loaded(): + model_loaded = True + # Get the actual loaded model size + # Check _current_model_size first (more reliable for actually loaded models) + model_size = getattr(tts_model, '_current_model_size', None) + if not model_size: + # Fallback to model_size attribute (which should be set when model loads) + model_size = getattr(tts_model, 'model_size', None) + except Exception: + # If there's an error checking, assume not loaded + model_loaded = False + model_size = None # Check if default model is downloaded (cached) model_downloaded = None @@ -1836,11 +1856,12 @@ async def get_active_tasks(): def _get_gpu_status() -> str: """Get GPU availability status.""" backend_type = get_backend_type() - if torch.cuda.is_available(): - return f"CUDA ({torch.cuda.get_device_name(0)})" - elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available(): - return "MPS (Apple Silicon)" - elif backend_type == "mlx": + if TORCH_AVAILABLE and torch is not None: + if torch.cuda.is_available(): + return f"CUDA ({torch.cuda.get_device_name(0)})" + elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available(): + return "MPS (Apple Silicon)" + if backend_type == "mlx": return "Metal (Apple Silicon via MLX)" return "None (CPU only)" @@ -1879,8 +1900,14 @@ async def shutdown_event(): """Run on application shutdown.""" print("voicebox API shutting down...") # Unload models to free memory - tts.unload_tts_model() - transcribe.unload_whisper_model() + try: + tts.unload_tts_model() + except Exception as e: + print(f"Warning: Failed to unload TTS model: {e}") + try: + transcribe.unload_whisper_model() + except Exception as e: + print(f"Warning: Failed to unload Whisper model: {e}") # ============================================ diff --git a/backend/providers/__init__.py b/backend/providers/__init__.py index f35ded88..cbe1c2cf 100644 --- a/backend/providers/__init__.py +++ b/backend/providers/__init__.py @@ -171,15 +171,12 @@ class ProviderManager: machine = platform.machine() if system == "Darwin" and machine == "arm64": - # Apple Silicon gets MLX + # Apple Silicon gets MLX bundled installed.append("apple-mlx") - - # PyTorch CPU is available on all platforms (check if bundled or downloaded) - # For now, assume it's bundled on macOS Intel, Windows, Linux - # Downloaded binaries will be detected below - if not (system == "Darwin" and machine == "arm64"): - # Non-Apple Silicon systems have PyTorch CPU bundled + elif system == "Windows" or (system == "Darwin" and machine != "arm64"): + # Windows and Intel macOS get PyTorch CPU bundled installed.append("pytorch-cpu") + # Linux: no bundled provider - users must download # Check for downloaded providers (Phase 2) providers_dir = _get_providers_dir() diff --git a/backend/utils/progress.py b/backend/utils/progress.py index 418a88c7..5d8202d2 100644 --- a/backend/utils/progress.py +++ b/backend/utils/progress.py @@ -49,11 +49,17 @@ class ProgressManager: queue.put_nowait(progress_data.copy()) except RuntimeError: # Not in async context (running in background thread) - # Use call_soon_threadsafe to safely put on queue + # Use asyncio.run_coroutine_threadsafe for better PyInstaller compatibility if self._main_loop and self._main_loop.is_running(): - self._main_loop.call_soon_threadsafe( - lambda q=queue, d=progress_data.copy(): q.put_nowait(d) if not q.full() else None - ) + async def put_data_async(): + try: + queue.put_nowait(progress_data.copy()) + except asyncio.QueueFull: + pass # Queue full, drop update + try: + asyncio.run_coroutine_threadsafe(put_data_async(), self._main_loop) + except Exception as e: + logger.warning(f"Failed to schedule progress update: {e}") else: logger.debug(f"No main loop available for {model_name}, skipping notification") except asyncio.QueueFull: diff --git a/backend/voicebox-server.spec b/backend/voicebox-server.spec index dc6fe876..a6ff1e41 100644 --- a/backend/voicebox-server.spec +++ b/backend/voicebox-server.spec @@ -3,7 +3,7 @@ from PyInstaller.utils.hooks import collect_data_files from PyInstaller.utils.hooks import collect_submodules datas = [] -hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.providers', 'backend.providers.base', 'backend.providers.bundled', 'backend.providers.types', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'pkg_resources.extern', 'backend.backends', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt'] +hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.providers', 'backend.providers.base', 'backend.providers.bundled', 'backend.providers.types', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'pkg_resources.extern', 'asyncio', 'asyncio.subprocess', 'concurrent.futures', 'concurrent.futures.thread', 'backend.backends', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt'] datas += collect_data_files('mlx') datas += collect_data_files('mlx_audio') hiddenimports += collect_submodules('jaraco') diff --git a/providers/pytorch-cpu/build.py b/providers/pytorch-cpu/build.py index 5d714d9c..e71996a7 100644 --- a/providers/pytorch-cpu/build.py +++ b/providers/pytorch-cpu/build.py @@ -55,23 +55,7 @@ def build_provider(): '--hidden-import', 'numpy', '--hidden-import', 'librosa', ]) - - # Exclude large unused modules to reduce binary size - args.extend([ - '--exclude-module', 'torch.utils.tensorboard', - '--exclude-module', 'tensorboard', - '--exclude-module', 'triton', - '--exclude-module', 'torch._dynamo', - '--exclude-module', 'torch._inductor', - '--exclude-module', 'torch.utils.benchmark', - '--exclude-module', 'IPython', - '--exclude-module', 'matplotlib', - '--exclude-module', 'PIL', - '--exclude-module', 'cv2', - '--exclude-module', 'torchvision', - '--exclude-module', 'torchaudio', - ]) - + args.extend([ '--noconfirm', '--clean', diff --git a/providers/pytorch-cuda/build.py b/providers/pytorch-cuda/build.py index 00630c3b..1eec21a8 100644 --- a/providers/pytorch-cuda/build.py +++ b/providers/pytorch-cuda/build.py @@ -57,23 +57,7 @@ def build_provider(): '--hidden-import', 'numpy', '--hidden-import', 'librosa', ]) - - # Exclude large unused modules to reduce binary size - args.extend([ - '--exclude-module', 'torch.utils.tensorboard', - '--exclude-module', 'tensorboard', - '--exclude-module', 'triton', - '--exclude-module', 'torch._dynamo', - '--exclude-module', 'torch._inductor', - '--exclude-module', 'torch.utils.benchmark', - '--exclude-module', 'IPython', - '--exclude-module', 'matplotlib', - '--exclude-module', 'PIL', - '--exclude-module', 'cv2', - '--exclude-module', 'torchvision', - '--exclude-module', 'torchaudio', - ]) - + args.extend([ '--noconfirm', '--clean', diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car index 6ed044ab..c9020db2 100644 Binary files a/tauri/src-tauri/gen/Assets.car and b/tauri/src-tauri/gen/Assets.car differ