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
11 changed files with 135 additions and 162 deletions
-14
View File
@@ -155,20 +155,6 @@ def _get_gpu_status() -> str:
return "MPS (Apple Silicon)"
elif backend_type == "mlx":
return "Metal (Apple Silicon via MLX)"
# Intel XPU (Arc / Data Center) via IPEX
try:
import intel_extension_for_pytorch # noqa: F401
if hasattr(torch, "xpu") and torch.xpu.is_available():
try:
xpu_name = torch.xpu.get_device_name(0)
except Exception:
xpu_name = "Intel GPU"
return f"XPU ({xpu_name})"
except ImportError:
pass
return "None (CPU only)"
-31
View File
@@ -126,37 +126,6 @@ def get_torch_device(
return "cpu"
def empty_device_cache(device: str) -> None:
"""
Free cached memory on the given device (CUDA or XPU).
Backends should call this after unloading models so VRAM is returned
to the OS.
"""
import torch
if device == "cuda" and torch.cuda.is_available():
torch.cuda.empty_cache()
elif device == "xpu" and hasattr(torch, "xpu"):
torch.xpu.empty_cache()
def manual_seed(seed: int, device: str) -> None:
"""
Set the random seed on both CPU and the active accelerator.
Covers CUDA and Intel XPU so that generation is reproducible
regardless of which GPU backend is in use.
"""
import torch
torch.manual_seed(seed)
if device == "cuda" and torch.cuda.is_available():
torch.cuda.manual_seed(seed)
elif device == "xpu" and hasattr(torch, "xpu"):
torch.xpu.manual_seed(seed)
async def combine_voice_prompts(
audio_paths: List[str],
reference_texts: List[str],
+10 -6
View File
@@ -18,8 +18,6 @@ from . import TTSBackend
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
patch_chatterbox_f32,
@@ -50,7 +48,7 @@ class ChatterboxTTSBackend:
self._model_load_lock = asyncio.Lock()
def _get_device(self) -> str:
return get_torch_device(force_cpu_on_mac=True, allow_xpu=True)
return get_torch_device(force_cpu_on_mac=True)
def is_loaded(self) -> bool:
return self.model is not None
@@ -119,7 +117,10 @@ class ChatterboxTTSBackend:
del self.model
self.model = None
self._device = None
empty_device_cache(device)
if device == "cuda":
import torch
torch.cuda.empty_cache()
logger.info("Chatterbox unloaded")
async def create_voice_prompt(
@@ -199,7 +200,7 @@ class ChatterboxTTSBackend:
import torch
if seed is not None:
manual_seed(seed, self._device)
torch.manual_seed(seed)
logger.info(f"[Chatterbox] Generating: lang={language}")
@@ -219,7 +220,10 @@ class ChatterboxTTSBackend:
else:
audio = np.asarray(wav, dtype=np.float32)
sample_rate = getattr(self.model, "sr", None) or getattr(self.model, "sample_rate", 24000)
sample_rate = (
getattr(self.model, "sr", None)
or getattr(self.model, "sample_rate", 24000)
)
return audio, sample_rate
+10 -6
View File
@@ -18,8 +18,6 @@ from . import TTSBackend
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
patch_chatterbox_f32,
@@ -50,7 +48,7 @@ class ChatterboxTurboTTSBackend:
self._model_load_lock = asyncio.Lock()
def _get_device(self) -> str:
return get_torch_device(force_cpu_on_mac=True, allow_xpu=True)
return get_torch_device(force_cpu_on_mac=True)
def is_loaded(self) -> bool:
return self.model is not None
@@ -118,7 +116,10 @@ class ChatterboxTurboTTSBackend:
del self.model
self.model = None
self._device = None
empty_device_cache(device)
if device == "cuda":
import torch
torch.cuda.empty_cache()
logger.info("Chatterbox Turbo unloaded")
async def create_voice_prompt(
@@ -180,7 +181,7 @@ class ChatterboxTurboTTSBackend:
import torch
if seed is not None:
manual_seed(seed, self._device)
torch.manual_seed(seed)
logger.info("[Chatterbox Turbo] Generating (English)")
@@ -199,7 +200,10 @@ class ChatterboxTurboTTSBackend:
else:
audio = np.asarray(wav, dtype=np.float32)
sample_rate = getattr(self.model, "sr", None) or getattr(self.model, "sample_rate", 24000)
sample_rate = (
getattr(self.model, "sr", None)
or getattr(self.model, "sample_rate", 24000)
)
return audio, sample_rate
+20 -19
View File
@@ -24,8 +24,6 @@ from . import TTSBackend
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
)
@@ -68,7 +66,7 @@ class HumeTadaBackend:
def _get_device(self) -> str:
# Force CPU on macOS — MPS has issues with flow matching
# and large vocab lm_head (>65536 output channels)
return get_torch_device(force_cpu_on_mac=True, allow_xpu=True)
return get_torch_device(force_cpu_on_mac=True)
def is_loaded(self) -> bool:
return self.model is not None
@@ -107,7 +105,6 @@ class HumeTadaBackend:
# package. The real package pulls in onnx/tensorboard/matplotlib via
# descript-audiotools, so we use a lightweight shim instead.
from ..utils.dac_shim import install_dac_shim
install_dac_shim()
import torch
@@ -145,12 +142,9 @@ class HumeTadaBackend:
allow_patterns=["tokenizer*", "special_tokens*"],
)
# Determine dtype — use bf16 on CUDA/XPU for ~50% memory savings
# Determine dtype — use bf16 on CUDA for ~50% memory savings
if device == "cuda" and torch.cuda.is_bf16_supported():
model_dtype = torch.bfloat16
elif device == "xpu":
# Intel Arc (Alchemist+) supports bf16 natively
model_dtype = torch.bfloat16
else:
model_dtype = torch.float32
@@ -159,14 +153,14 @@ class HumeTadaBackend:
# This avoids monkey-patching AutoTokenizer.from_pretrained
# which corrupts the classmethod descriptor for other engines.
from tada.modules.aligner import AlignerConfig
AlignerConfig.tokenizer_name = tokenizer_path
# Load encoder (only needed for voice prompt encoding)
from tada.modules.encoder import Encoder
logger.info("Loading TADA encoder...")
self.encoder = Encoder.from_pretrained(TADA_CODEC_REPO, subfolder="encoder").to(device)
self.encoder = Encoder.from_pretrained(
TADA_CODEC_REPO, subfolder="encoder"
).to(device)
self.encoder.eval()
# Load the causal LM (includes decoder for wav generation).
@@ -175,11 +169,12 @@ class HumeTadaBackend:
# which hits the gated repo. Pre-load the config from HF,
# inject the local tokenizer path, then pass it in.
from tada.modules.tada import TadaForCausalLM, TadaConfig
logger.info(f"Loading TADA {model_size} model...")
config = TadaConfig.from_pretrained(repo)
config.tokenizer_name = tokenizer_path
self.model = TadaForCausalLM.from_pretrained(repo, config=config, torch_dtype=model_dtype).to(device)
self.model = TadaForCausalLM.from_pretrained(
repo, config=config, torch_dtype=model_dtype
).to(device)
self.model.eval()
logger.info(f"HumeAI TADA {model_size} loaded successfully on {device}")
@@ -193,11 +188,11 @@ class HumeTadaBackend:
del self.encoder
self.encoder = None
device = self._device
self._device = None
if device:
empty_device_cache(device)
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
logger.info("HumeAI TADA unloaded")
@@ -218,7 +213,9 @@ class HumeTadaBackend:
"""
await self.load_model(self.model_size)
cache_key = ("tada_" + get_cache_key(audio_path, reference_text)) if use_cache else None
cache_key = (
"tada_" + get_cache_key(audio_path, reference_text)
) if use_cache else None
if cache_key:
cached = get_cached_voice_prompt(cache_key)
@@ -242,7 +239,9 @@ class HumeTadaBackend:
# Encode with forced alignment
text_arg = [reference_text] if reference_text else None
prompt = self.encoder(audio, text=text_arg, sample_rate=sr)
prompt = self.encoder(
audio, text=text_arg, sample_rate=sr
)
# Serialize EncoderOutput to a dict of CPU tensors for caching
prompt_dict = {}
@@ -300,7 +299,9 @@ class HumeTadaBackend:
from tada.modules.encoder import EncoderOutput
if seed is not None:
manual_seed(seed, self._device)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
device = self._device
+11 -17
View File
@@ -12,14 +12,7 @@ from typing import Optional, Tuple
import numpy as np
from . import TTSBackend
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
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
logger = logging.getLogger(__name__)
@@ -37,7 +30,7 @@ class LuxTTSBackend:
self._device = None
def _get_device(self) -> str:
return get_torch_device(allow_mps=True, allow_xpu=True)
return get_torch_device(allow_mps=True)
def is_loaded(self) -> bool:
return self.model is not None
@@ -76,12 +69,9 @@ class LuxTTSBackend:
if device == "cpu":
import os
threads = os.cpu_count() or 4
self.model = LuxTTS(
model_path=LUXTTS_HF_REPO,
device="cpu",
threads=min(threads, 8),
model_path=LUXTTS_HF_REPO, device="cpu", threads=min(threads, 8),
)
else:
self.model = LuxTTS(model_path=LUXTTS_HF_REPO, device=device)
@@ -91,12 +81,12 @@ class LuxTTSBackend:
def unload_model(self) -> None:
"""Unload model to free memory."""
if self.model is not None:
device = self.device
del self.model
self.model = None
self._device = None
empty_device_cache(device)
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
logger.info("LuxTTS unloaded")
@@ -164,8 +154,12 @@ class LuxTTSBackend:
await self.load_model()
def _generate_sync():
import torch
if seed is not None:
manual_seed(seed, self.device)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
wav = self.model.generate_speech(
text=text,
+9 -26
View File
@@ -6,7 +6,6 @@ from typing import Optional, List, Tuple
import asyncio
import logging
import numpy as np
import os
from pathlib import Path
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 .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.hf_offline_patch import force_offline_if_cached
class MLXTTSBackend:
@@ -96,32 +96,13 @@ class MLXTTSBackend:
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"
logger.info("[PATCH] Model %s is cached, forcing HF_HUB_OFFLINE=1 to avoid network requests", model_size)
with model_load_progress(model_name, is_cached):
from mlx_audio.tts import load
try:
with model_load_progress(model_name, is_cached):
from mlx_audio.tts import load
logger.info("Loading MLX TTS model %s...", 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():
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)
with force_offline_if_cached(is_cached, model_name):
self.model = load(model_path)
self._current_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}")
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
logger.info("MLX Whisper model %s loaded successfully", model_size)
+24 -19
View File
@@ -14,13 +14,12 @@ from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
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
from ..utils.hf_offline_patch import force_offline_if_cached
class PyTorchTTSBackend:
@@ -98,18 +97,19 @@ class PyTorchTTSBackend:
model_path = self._get_model_path(model_size)
logger.info("Loading TTS model %s on %s...", model_size, self.device)
if self.device == "cpu":
self.model = Qwen3TTSModel.from_pretrained(
model_path,
torch_dtype=torch.float32,
low_cpu_mem_usage=False,
)
else:
self.model = Qwen3TTSModel.from_pretrained(
model_path,
device_map=self.device,
torch_dtype=torch.bfloat16,
)
with force_offline_if_cached(is_cached, model_name):
if self.device == "cpu":
self.model = Qwen3TTSModel.from_pretrained(
model_path,
torch_dtype=torch.float32,
low_cpu_mem_usage=False,
)
else:
self.model = Qwen3TTSModel.from_pretrained(
model_path,
device_map=self.device,
torch_dtype=torch.bfloat16,
)
self._current_model_size = model_size
self.model_size = model_size
@@ -122,7 +122,8 @@ class PyTorchTTSBackend:
self.model = None
self._current_model_size = None
empty_device_cache(self.device)
if torch.cuda.is_available():
torch.cuda.empty_cache()
logger.info("TTS model unloaded")
@@ -214,7 +215,9 @@ class PyTorchTTSBackend:
"""Run synchronous generation in thread pool."""
# Set seed if provided
if seed is not None:
manual_seed(seed, self.device)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
# Generate audio - this is the blocking operation
wavs, sample_rate = self.model.generate_voice_clone(
@@ -281,8 +284,9 @@ class PyTorchSTTBackend:
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)
self.processor = WhisperProcessor.from_pretrained(model_name)
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
with force_offline_if_cached(is_cached, progress_model_name):
self.processor = WhisperProcessor.from_pretrained(model_name)
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
self.model.to(self.device)
self.model_size = model_size
@@ -296,7 +300,8 @@ class PyTorchSTTBackend:
self.model = None
self.processor = None
empty_device_cache(self.device)
if torch.cuda.is_available():
torch.cuda.empty_cache()
logger.info("Whisper model unloaded")
+1 -9
View File
@@ -110,11 +110,6 @@ async def health():
vram_used = None
if has_cuda:
vram_used = torch.cuda.memory_allocated() / 1024 / 1024
elif has_xpu:
try:
vram_used = torch.xpu.memory_allocated() / 1024 / 1024
except Exception:
pass # memory_allocated() may not be available on all IPEX versions
model_loaded = False
model_size = None
@@ -167,10 +162,7 @@ async def health():
gpu_type=gpu_type,
vram_used_mb=vram_used,
backend_type=backend_type,
backend_variant=os.environ.get(
"VOICEBOX_BACKEND_VARIANT",
"cuda" if torch.cuda.is_available() else ("xpu" if has_xpu else "cpu"),
),
backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", "cuda" if torch.cuda.is_available() else "cpu"),
)
+49 -2
View File
@@ -1,17 +1,64 @@
"""Monkey-patch huggingface_hub to force offline mode with cached models.
Prevents mlx_audio from making network requests when models are already
downloaded. Must be imported BEFORE mlx_audio.
Prevents mlx_audio / transformers from making network requests when models
are already downloaded. Must be imported BEFORE mlx_audio.
"""
import logging
import os
from contextlib import contextmanager
from pathlib import Path
from typing import Optional, Union
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():
"""Monkey-patch huggingface_hub to force offline mode."""
try:
+1 -13
View File
@@ -69,22 +69,10 @@ setup-python:
}
Write-Host "Installing Python dependencies..."
& "{{ python }}" -m pip install --upgrade pip -q
$gpus = Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name
Write-Host "Detected GPUs: $($gpus -join ', ')"
$hasNvidia = ($gpus | Where-Object { $_ -match 'NVIDIA' }).Count -gt 0
$hasIntelArc = ($gpus | Where-Object { $_ -match 'Arc' }).Count -gt 0
$hasNvidia = $null -ne (Get-WmiObject Win32_VideoController | Where-Object { $_.Name -match 'NVIDIA' })
if ($hasNvidia) { \
Write-Host "NVIDIA GPU detected — installing PyTorch with CUDA support..."; \
& "{{ pip }}" install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128; \
} elseif ($hasIntelArc) { \
Write-Host "Intel Arc GPU detected — installing PyTorch with XPU support..."; \
& "{{ pip }}" install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/xpu; \
& "{{ pip }}" install intel-extension-for-pytorch --index-url https://download.pytorch.org/whl/xpu; \
} else { \
Write-Host "No NVIDIA or Intel Arc GPU detected — using CPU-only PyTorch."; \
Write-Host "If you have an Intel Arc GPU, install XPU support manually:"; \
Write-Host " pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/xpu"; \
Write-Host " pip install intel-extension-for-pytorch --index-url https://download.pytorch.org/whl/xpu"; \
}
& "{{ pip }}" install -r {{ backend_dir }}/requirements.txt
& "{{ pip }}" install --no-deps chatterbox-tts