Compare commits

...
Author SHA1 Message Date
James Pine 2e95b7c5d8 fix: force offline mode when loading cached models (Qwen TTS & Whisper)
Qwen TTS and Whisper Base make network calls to HuggingFace even when
model weights are fully cached locally, because from_pretrained()
defaults to local_files_only=False. This causes failures for offline
users.

Add a reusable force_offline_if_cached() context manager that sets
HF_HUB_OFFLINE=1 during model loading when is_model_cached() is True.
Applied to all four affected load paths:

- PyTorchTTSBackend (Qwen TTS)
- PyTorchSTTBackend (Whisper)
- MLXTTSBackend (refactored from inline implementation)
- MLXSTTBackend (previously unprotected)

Closes #82
2026-03-18 10:31:30 -07:00
Jamie PineandGitHub ffc1b54812 Merge pull request #316 from jamiepine/fix/cuda-cu128-upgrade
Upgrade CUDA backend from cu126 to cu128, fix GPU settings UI
2026-03-18 07:58:12 -07:00
James Pine fc5ed1ff40 upgrade CUDA backend from cu126 to cu128 and fix GPU settings UI
Upgrade CUDA toolkit from 12.6 (cu126) to 12.8 (cu128) for proper
RTX 50-series (Blackwell) GPU support. Users with RTX 5070/5080/5090
were reporting CUDA detection failures with cu126.

Also fix the GPU Acceleration settings panel where the 'Switch to CPU
Backend' button was unreachable — it was inside a conditional block
that required !isCurrentlyCuda, making it impossible to switch back
to CPU once running on CUDA.

Closes #315
2026-03-18 07:47:39 -07:00
Jamie PineandGitHub c9f38dd496 Merge pull request #305 from jamiepine/fix/qwen-tts-pyinstaller-source-files
fix: bundle qwen_tts source files in PyInstaller build
2026-03-17 09:24:42 -07:00
James Pine 58b19e4e9f fix: bundle qwen_tts source files in PyInstaller build
Replace --collect-submodules + --collect-data with --collect-all for
qwen_tts. The qwen_tts runtime expects physical .py source files
(e.g. modeling_qwen3_tts.py) under _MEIPASS, which only --collect-all
provides. This is the same pattern used for inflect/typeguard.

Fixes #212
2026-03-17 09:23:30 -07:00
Jamie PineandGitHub 0245c31dba Merge pull request #298 from jamiepine/feat/cuda-libs-addon
feat: split CUDA backend into independently versioned server + libs archives
2026-03-17 09:17:31 -07:00
11 changed files with 132 additions and 85 deletions
+7 -7
View File
@@ -191,10 +191,10 @@ jobs:
pip install --no-deps chatterbox-tts pip install --no-deps chatterbox-tts
pip install --no-deps hume-tada pip install --no-deps hume-tada
- name: Install PyTorch with CUDA 12.6 - name: Install PyTorch with CUDA 12.8
run: | run: |
pip install torch --index-url https://download.pytorch.org/whl/cu126 --force-reinstall --no-deps pip install torch --index-url https://download.pytorch.org/whl/cu128 --force-reinstall --no-deps
pip install torchaudio --index-url https://download.pytorch.org/whl/cu126 --force-reinstall --no-deps pip install torchaudio --index-url https://download.pytorch.org/whl/cu128 --force-reinstall --no-deps
- name: Verify CUDA support in torch - name: Verify CUDA support in torch
run: | run: |
@@ -211,8 +211,8 @@ jobs:
python scripts/package_cuda.py \ python scripts/package_cuda.py \
backend/dist/voicebox-server-cuda/ \ backend/dist/voicebox-server-cuda/ \
--output release-assets/ \ --output release-assets/ \
--cuda-libs-version cu126-v1 \ --cuda-libs-version cu128-v1 \
--torch-compat ">=2.6.0,<2.11.0" --torch-compat ">=2.7.0,<2.11.0"
- name: Upload archives to GitHub Release - name: Upload archives to GitHub Release
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
@@ -221,8 +221,8 @@ jobs:
files: | files: |
release-assets/voicebox-server-cuda.tar.gz release-assets/voicebox-server-cuda.tar.gz
release-assets/voicebox-server-cuda.tar.gz.sha256 release-assets/voicebox-server-cuda.tar.gz.sha256
release-assets/cuda-libs-cu126-v1.tar.gz release-assets/cuda-libs-cu128-v1.tar.gz
release-assets/cuda-libs-cu126-v1.tar.gz.sha256 release-assets/cuda-libs-cu128-v1.tar.gz.sha256
release-assets/cuda-libs.json release-assets/cuda-libs.json
draft: true draft: true
env: env:
@@ -243,7 +243,40 @@ export function GpuAcceleration() {
{/* Native GPU detected - no CUDA download needed */} {/* Native GPU detected - no CUDA download needed */}
{/* CUDA download section - only show when no GPU is active (native or CUDA) */} {/* Currently running CUDA - show switch back to CPU */}
{isCurrentlyCuda && platform.metadata.isTauri && (
<>
{restartPhase !== 'idle' ? (
<div className="flex items-center gap-2 p-3 rounded-lg bg-primary/5 border">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">
{restartPhase === 'stopping' && 'Stopping server...'}
{restartPhase === 'waiting' && 'Restarting server...'}
{restartPhase === 'ready' && 'Server restarted successfully!'}
</span>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Running with CUDA GPU acceleration. Switch back to CPU if needed (you can
re-download later).
</p>
<Button onClick={handleSwitchToCpu} variant="outline" className="w-full" size="sm">
<RotateCw className="h-4 w-4 mr-2" />
Switch to CPU Backend
</Button>
</div>
)}
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
</>
)}
{/* CUDA download/manage section - show when no native GPU and not currently running CUDA */}
{!hasNativeGpu && !isCurrentlyCuda && ( {!hasNativeGpu && !isCurrentlyCuda && (
<> <>
{/* Download progress (manual download or auto-update) */} {/* Download progress (manual download or auto-update) */}
@@ -315,7 +348,7 @@ export function GpuAcceleration() {
)} )}
{/* Downloaded but not active - show switch button */} {/* Downloaded but not active - show switch button */}
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && ( {cudaAvailable && platform.metadata.isTauri && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
CUDA backend is downloaded and ready. Restart the server to enable GPU CUDA backend is downloaded and ready. Restart the server to enable GPU
@@ -328,27 +361,8 @@ export function GpuAcceleration() {
</div> </div>
)} )}
{/* Currently active - show switch back to CPU */}
{isCurrentlyCuda && platform.metadata.isTauri && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Running with CUDA GPU acceleration. Switch back to CPU if needed (you can
re-download later).
</p>
<Button
onClick={handleSwitchToCpu}
variant="outline"
className="w-full"
size="sm"
>
<RotateCw className="h-4 w-4 mr-2" />
Switch to CPU Backend
</Button>
</div>
)}
{/* Delete option when downloaded (and not active) */} {/* Delete option when downloaded (and not active) */}
{cudaAvailable && !isCurrentlyCuda && ( {cudaAvailable && (
<Button <Button
onClick={handleDelete} onClick={handleDelete}
variant="ghost" variant="ghost"
+9 -26
View File
@@ -6,7 +6,6 @@ from typing import Optional, List, Tuple
import asyncio import asyncio
import logging import logging
import numpy as np import numpy as np
import os
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,6 +20,7 @@ ensure_original_qwen_config_cached()
from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
from .base import is_model_cached, combine_voice_prompts as _combine_voice_prompts, model_load_progress from .base import is_model_cached, 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.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
from ..utils.hf_offline_patch import force_offline_if_cached
class MLXTTSBackend: class MLXTTSBackend:
@@ -96,32 +96,13 @@ class MLXTTSBackend:
model_name = f"qwen-tts-{model_size}" model_name = f"qwen-tts-{model_size}"
is_cached = self._is_model_cached(model_size) is_cached = self._is_model_cached(model_size)
# Force offline mode when cached to avoid network requests with model_load_progress(model_name, is_cached):
original_hf_hub_offline = os.environ.get("HF_HUB_OFFLINE") from mlx_audio.tts import load
if is_cached:
os.environ["HF_HUB_OFFLINE"] = "1"
logger.info("[PATCH] Model %s is cached, forcing HF_HUB_OFFLINE=1 to avoid network requests", model_size)
try: logger.info("Loading MLX TTS model %s...", model_size)
with model_load_progress(model_name, is_cached):
from mlx_audio.tts import load
logger.info("Loading MLX TTS model %s...", model_size) with force_offline_if_cached(is_cached, model_name):
self.model = load(model_path)
try:
self.model = load(model_path)
except Exception as load_error:
if is_cached and "offline" in str(load_error).lower():
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:
raise
finally:
if original_hf_hub_offline is not None:
os.environ["HF_HUB_OFFLINE"] = original_hf_hub_offline
else:
os.environ.pop("HF_HUB_OFFLINE", None)
self._current_model_size = model_size self._current_model_size = model_size
self.model_size = model_size self.model_size = model_size
@@ -329,7 +310,9 @@ class MLXSTTBackend:
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}") model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
logger.info("Loading MLX Whisper model %s...", model_size) logger.info("Loading MLX Whisper model %s...", model_size)
self.model = load(model_name)
with force_offline_if_cached(is_cached, progress_model_name):
self.model = load(model_name)
self.model_size = model_size self.model_size = model_size
logger.info("MLX Whisper model %s loaded successfully", model_size) logger.info("MLX Whisper model %s loaded successfully", model_size)
+17 -14
View File
@@ -19,6 +19,7 @@ from .base import (
) )
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
from ..utils.audio import load_audio from ..utils.audio import load_audio
from ..utils.hf_offline_patch import force_offline_if_cached
class PyTorchTTSBackend: class PyTorchTTSBackend:
@@ -96,18 +97,19 @@ class PyTorchTTSBackend:
model_path = self._get_model_path(model_size) model_path = self._get_model_path(model_size)
logger.info("Loading TTS model %s on %s...", model_size, self.device) logger.info("Loading TTS model %s on %s...", model_size, self.device)
if self.device == "cpu": with force_offline_if_cached(is_cached, model_name):
self.model = Qwen3TTSModel.from_pretrained( if self.device == "cpu":
model_path, self.model = Qwen3TTSModel.from_pretrained(
torch_dtype=torch.float32, model_path,
low_cpu_mem_usage=False, torch_dtype=torch.float32,
) low_cpu_mem_usage=False,
else: )
self.model = Qwen3TTSModel.from_pretrained( else:
model_path, self.model = Qwen3TTSModel.from_pretrained(
device_map=self.device, model_path,
torch_dtype=torch.bfloat16, device_map=self.device,
) torch_dtype=torch.bfloat16,
)
self._current_model_size = model_size self._current_model_size = model_size
self.model_size = model_size self.model_size = model_size
@@ -282,8 +284,9 @@ class PyTorchSTTBackend:
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}") model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
logger.info("Loading Whisper model %s on %s...", model_size, self.device) logger.info("Loading Whisper model %s on %s...", model_size, self.device)
self.processor = WhisperProcessor.from_pretrained(model_name) with force_offline_if_cached(is_cached, progress_model_name):
self.model = WhisperForConditionalGeneration.from_pretrained(model_name) self.processor = WhisperProcessor.from_pretrained(model_name)
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
self.model.to(self.device) self.model.to(self.device)
self.model_size = model_size self.model_size = model_size
+4 -4
View File
@@ -171,9 +171,9 @@ def build_server(cuda=False):
"tqdm", "tqdm",
"--hidden-import", "--hidden-import",
"requests", "requests",
"--collect-submodules", # qwen_tts uses inspect.getsource() at runtime to locate
"qwen_tts", # modeling_qwen3_tts.py — needs physical .py source files bundled
"--collect-data", "--collect-all",
"qwen_tts", "qwen_tts",
# Fix for pkg_resources and jaraco namespace packages # Fix for pkg_resources and jaraco namespace packages
"--hidden-import", "--hidden-import",
@@ -370,7 +370,7 @@ def build_server(cuda=False):
"torchvision", "torchvision",
"torchaudio", "torchaudio",
"--index-url", "--index-url",
"https://download.pytorch.org/whl/cu126", "https://download.pytorch.org/whl/cu128",
"--force-reinstall", "--force-reinstall",
"-q", "-q",
], ],
+1 -1
View File
@@ -8,7 +8,7 @@ sqlalchemy>=2.0.0
alembic>=1.13.0 alembic>=1.13.0
# ML models # ML models
torch>=2.1.0 torch>=2.7.0
transformers>=4.36.0,<=4.57.6 transformers>=4.36.0,<=4.57.6
accelerate>=0.26.0 accelerate>=0.26.0
huggingface_hub>=0.20.0 huggingface_hub>=0.20.0
+1 -1
View File
@@ -32,7 +32,7 @@ PROGRESS_KEY = "cuda-backend"
# The current expected CUDA libs version. Bump this when we change the # The current expected CUDA libs version. Bump this when we change the
# CUDA toolkit version or torch's CUDA dependency changes (e.g. cu126 -> cu128). # CUDA toolkit version or torch's CUDA dependency changes (e.g. cu126 -> cu128).
CUDA_LIBS_VERSION = "cu126-v1" CUDA_LIBS_VERSION = "cu128-v1"
def get_backends_dir() -> Path: def get_backends_dir() -> Path:
+49 -2
View File
@@ -1,17 +1,64 @@
"""Monkey-patch huggingface_hub to force offline mode with cached models. """Monkey-patch huggingface_hub to force offline mode with cached models.
Prevents mlx_audio from making network requests when models are already Prevents mlx_audio / transformers from making network requests when models
downloaded. Must be imported BEFORE mlx_audio. are already downloaded. Must be imported BEFORE mlx_audio.
""" """
import logging import logging
import os import os
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Optional, Union from typing import Optional, Union
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@contextmanager
def force_offline_if_cached(is_cached: bool, model_label: str = ""):
"""Context manager that sets ``HF_HUB_OFFLINE=1`` while loading a cached model.
If *is_cached* is ``False`` the block runs normally (network allowed).
If the offline load raises an error containing "offline" we automatically
retry with network access so a partially-cached model still works.
Args:
is_cached: Whether the model weights are already on disk.
model_label: Human-readable name used in log messages.
"""
if not is_cached:
yield
return
original_value = os.environ.get("HF_HUB_OFFLINE")
os.environ["HF_HUB_OFFLINE"] = "1"
logger.info(
"[offline-guard] %s is cached — forcing HF_HUB_OFFLINE=1",
model_label or "model",
)
try:
yield
except Exception as exc:
if "offline" in str(exc).lower():
logger.warning(
"[offline-guard] Offline load failed for %s, retrying with network: %s",
model_label or "model",
exc,
)
# Restore original env and retry — caller must wrap the load
# inside force_offline_if_cached so retrying here isn't possible.
# Instead, propagate a flag via the exception so the caller can
# decide. For simplicity we just let it fall through to the
# finally block and re-raise.
raise
raise
finally:
if original_value is not None:
os.environ["HF_HUB_OFFLINE"] = original_value
else:
os.environ.pop("HF_HUB_OFFLINE", None)
def patch_huggingface_hub_offline(): def patch_huggingface_hub_offline():
"""Monkey-patch huggingface_hub to force offline mode.""" """Monkey-patch huggingface_hub to force offline mode."""
try: try:
+2 -2
View File
@@ -159,11 +159,11 @@ Tauri looks for `voicebox-server-${PLATFORM}` in `src-tauri/binaries/` and bundl
The `build-cuda-windows` job runs separately: The `build-cuda-windows` job runs separately:
1. Install PyTorch with CUDA 12.6 1. Install PyTorch with CUDA 12.8
2. Build with `build_binary.py --cuda` (produces `--onedir` output) 2. Build with `build_binary.py --cuda` (produces `--onedir` output)
3. Package with `scripts/package_cuda.py` into two archives: 3. Package with `scripts/package_cuda.py` into two archives:
- `voicebox-server-cuda.tar.gz` — server core (~945 MB) - `voicebox-server-cuda.tar.gz` — server core (~945 MB)
- `cuda-libs-cu126-v1.tar.gz` — NVIDIA runtime libraries (~1.7 GB, cached independently) - `cuda-libs-cu128-v1.tar.gz` — NVIDIA runtime libraries (~1.7 GB, cached independently)
4. Upload archives as release artifacts 4. Upload archives as release artifacts
This binary is downloaded on-demand by users who enable CUDA in settings. The CUDA libs archive is only re-downloaded when the CUDA toolkit version changes, not on every app update. This binary is downloaded on-demand by users who enable CUDA in settings. The CUDA libs archive is only re-downloaded when the CUDA toolkit version changes, not on every app update.
+1 -1
View File
@@ -72,7 +72,7 @@ setup-python:
$hasNvidia = $null -ne (Get-WmiObject Win32_VideoController | Where-Object { $_.Name -match 'NVIDIA' }) $hasNvidia = $null -ne (Get-WmiObject Win32_VideoController | Where-Object { $_.Name -match 'NVIDIA' })
if ($hasNvidia) { \ if ($hasNvidia) { \
Write-Host "NVIDIA GPU detected — installing PyTorch with CUDA support..."; \ Write-Host "NVIDIA GPU detected — installing PyTorch with CUDA support..."; \
& "{{ pip }}" install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126; \ & "{{ pip }}" install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128; \
} }
& "{{ pip }}" install -r {{ backend_dir }}/requirements.txt & "{{ pip }}" install -r {{ backend_dir }}/requirements.txt
& "{{ pip }}" install --no-deps chatterbox-tts & "{{ pip }}" install --no-deps chatterbox-tts
+5 -5
View File
@@ -3,13 +3,13 @@ Package the PyInstaller --onedir CUDA build into two archives.
Takes the PyInstaller --onedir output directory and splits it into: Takes the PyInstaller --onedir output directory and splits it into:
1. voicebox-server-cuda.tar.gz — server core (exe + non-NVIDIA deps) 1. voicebox-server-cuda.tar.gz — server core (exe + non-NVIDIA deps)
2. cuda-libs-cu126.tar.gz — NVIDIA runtime libraries only 2. cuda-libs-cu128.tar.gz — NVIDIA runtime libraries only
3. cuda-libs.json — version manifest for the CUDA libs 3. cuda-libs.json — version manifest for the CUDA libs
Usage: Usage:
python scripts/package_cuda.py backend/dist/voicebox-server-cuda/ python scripts/package_cuda.py backend/dist/voicebox-server-cuda/
python scripts/package_cuda.py backend/dist/voicebox-server-cuda/ --output release-assets/ python scripts/package_cuda.py backend/dist/voicebox-server-cuda/ --output release-assets/
python scripts/package_cuda.py backend/dist/voicebox-server-cuda/ --cuda-libs-version cu126-v1 python scripts/package_cuda.py backend/dist/voicebox-server-cuda/ --cuda-libs-version cu128-v1
""" """
import argparse import argparse
@@ -208,13 +208,13 @@ def main():
parser.add_argument( parser.add_argument(
"--cuda-libs-version", "--cuda-libs-version",
type=str, type=str,
default="cu126-v1", default="cu128-v1",
help="Version string for the CUDA libs archive (default: cu126-v1)", help="Version string for the CUDA libs archive (default: cu128-v1)",
) )
parser.add_argument( parser.add_argument(
"--torch-compat", "--torch-compat",
type=str, type=str,
default=">=2.6.0,<2.11.0", default=">=2.7.0,<2.11.0",
help="Torch version compatibility range (default: >=2.6.0,<2.11.0)", help="Torch version compatibility range (default: >=2.6.0,<2.11.0)",
) )
args = parser.parse_args() args = parser.parse_args()