Compare commits

..
Author SHA1 Message Date
James Pine 707046237c fix: complete Intel XPU support — device-aware seeding, GPU status reporting, and setup detection
Address CodeRabbit review feedback and user-reported GPU acceleration failure:

- Use shared manual_seed() in chatterbox, chatterbox_turbo, and luxtts
  backends so XPU (and future accelerators) get proper device seeding
- Add XPU branch to _get_gpu_status() so startup log reports Intel Arc
  GPUs instead of 'None (CPU only)'
- Add XPU VRAM reporting and correct backend_variant fallback in the
  /health endpoint
- Switch justfile GPU detection from Get-WmiObject to Get-CimInstance,
  simplify the Arc regex to match 'Arc' (not 'Intel.*Arc'), log
  detected GPUs, and print manual install instructions on miss

Resolves the root cause where IPEX was silently not installed due to
WMI detection failure, causing CPU-only fallback on Intel Arc systems.
2026-03-18 17:01:12 -07:00
James Pine 83ebababe7 feat: add Intel Arc (XPU) GPU support across all backends
Auto-detect Intel Arc GPUs during Windows setup and install PyTorch
with XPU support + intel-extension-for-pytorch. Enable allow_xpu=True
on all TTS backends (Chatterbox, Chatterbox Turbo, Hume TADA, LuxTTS)
that previously only supported CUDA. Add shared empty_device_cache()
and manual_seed() helpers in base.py to handle XPU memory management
and reproducible seeding alongside CUDA.
2026-03-18 11:24:51 -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
27 changed files with 196 additions and 671 deletions
+7 -9
View File
@@ -63,7 +63,6 @@ jobs:
pip install -r backend/requirements.txt
pip install --no-deps chatterbox-tts
pip install --no-deps hume-tada
git clone --recursive --depth 1 https://github.com/FunAudioLLM/CosyVoice.git backend/vendors/CosyVoice
- name: Install MLX dependencies (Apple Silicon only)
if: matrix.backend == 'mlx'
@@ -191,12 +190,11 @@ jobs:
pip install -r backend/requirements.txt
pip install --no-deps chatterbox-tts
pip install --no-deps hume-tada
git clone --recursive --depth 1 https://github.com/FunAudioLLM/CosyVoice.git backend/vendors/CosyVoice
- name: Install PyTorch with CUDA 12.6
- name: Install PyTorch with CUDA 12.8
run: |
pip install torch --index-url https://download.pytorch.org/whl/cu126 --force-reinstall --no-deps
pip install torchaudio --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/cu128 --force-reinstall --no-deps
- name: Verify CUDA support in torch
run: |
@@ -213,8 +211,8 @@ jobs:
python scripts/package_cuda.py \
backend/dist/voicebox-server-cuda/ \
--output release-assets/ \
--cuda-libs-version cu126-v1 \
--torch-compat ">=2.6.0,<2.11.0"
--cuda-libs-version cu128-v1 \
--torch-compat ">=2.7.0,<2.11.0"
- name: Upload archives to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
@@ -223,8 +221,8 @@ jobs:
files: |
release-assets/voicebox-server-cuda.tar.gz
release-assets/voicebox-server-cuda.tar.gz.sha256
release-assets/cuda-libs-cu126-v1.tar.gz
release-assets/cuda-libs-cu126-v1.tar.gz.sha256
release-assets/cuda-libs-cu128-v1.tar.gz
release-assets/cuda-libs-cu128-v1.tar.gz.sha256
release-assets/cuda-libs.json
draft: true
env:
-3
View File
@@ -59,9 +59,6 @@ tauri/src-tauri/gen/partial.plist
# Windows artifacts
nul
# Vendored source clones (fetched at setup time)
backend/vendors/
# Temporary
tmp/
temp/
-4
View File
@@ -39,7 +39,6 @@ RUN pip install --no-cache-dir --prefix=/install --no-deps chatterbox-tts
RUN pip install --no-cache-dir --prefix=/install --no-deps hume-tada
RUN pip install --no-cache-dir --prefix=/install \
git+https://github.com/QwenLM/Qwen3-TTS.git
RUN git clone --recursive --depth 1 https://github.com/FunAudioLLM/CosyVoice.git /build/CosyVoice
# === Stage 3: Runtime ===
@@ -63,9 +62,6 @@ COPY --from=backend-builder /install /usr/local
# Copy backend application code
COPY --chown=voicebox:voicebox backend/ /app/backend/
# Copy CosyVoice source from builder stage
COPY --from=backend-builder --chown=voicebox:voicebox /build/CosyVoice/ /app/backend/vendors/CosyVoice/
# Copy built frontend from frontend stage
COPY --from=frontend --chown=voicebox:voicebox /build/web/dist /app/frontend/
@@ -22,8 +22,6 @@ const ENGINE_OPTIONS = [
{ value: 'chatterbox_turbo', label: 'Chatterbox Turbo' },
{ value: 'tada:1B', label: 'TADA 1B' },
{ value: 'tada:3B', label: 'TADA 3B Multilingual' },
{ value: 'cosyvoice:v2', label: 'CosyVoice2 0.5B' },
{ value: 'cosyvoice:v3', label: 'CosyVoice3 0.5B' },
] as const;
const ENGINE_DESCRIPTIONS: Record<string, string> = {
@@ -32,7 +30,6 @@ const ENGINE_DESCRIPTIONS: Record<string, string> = {
chatterbox: '23 languages, incl. Hebrew',
chatterbox_turbo: 'English, [laugh] [cough] tags',
tada: 'HumeAI, 700s+ coherent audio',
cosyvoice: 'Alibaba, instruct + cloning',
};
/** Engines that only support English and should force language to 'en' on select. */
@@ -41,7 +38,6 @@ const ENGLISH_ONLY_ENGINES = new Set(['luxtts', 'chatterbox_turbo']);
function getSelectValue(engine: string, modelSize?: string): string {
if (engine === 'qwen') return `qwen:${modelSize || '1.7B'}`;
if (engine === 'tada') return `tada:${modelSize || '1B'}`;
if (engine === 'cosyvoice') return `cosyvoice:${modelSize || 'v2'}`;
return engine;
}
@@ -70,15 +66,6 @@ function handleEngineChange(form: UseFormReturn<GenerationFormValues>, value: st
form.setValue('language', available[0]?.value ?? 'en');
}
}
} else if (value.startsWith('cosyvoice:')) {
const [, modelSize] = value.split(':');
form.setValue('engine', 'cosyvoice');
form.setValue('modelSize', modelSize as 'v2' | 'v3');
const currentLang = form.getValues('language');
const available = getLanguageOptionsForEngine('cosyvoice');
if (!available.some((l) => l.value === currentLang)) {
form.setValue('language', available[0]?.value ?? 'en');
}
} else {
form.setValue('engine', value as GenerationFormValues['engine']);
form.setValue('modelSize', undefined as unknown as '1.7B' | '0.6B');
@@ -243,7 +243,40 @@ export function GpuAcceleration() {
{/* 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 && (
<>
{/* Download progress (manual download or auto-update) */}
@@ -315,7 +348,7 @@ export function GpuAcceleration() {
)}
{/* Downloaded but not active - show switch button */}
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
{cudaAvailable && platform.metadata.isTauri && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
CUDA backend is downloaded and ready. Restart the server to enable GPU
@@ -328,27 +361,8 @@ export function GpuAcceleration() {
</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) */}
{cudaAvailable && !isCurrentlyCuda && (
{cudaAvailable && (
<Button
onClick={handleDelete}
variant="ghost"
@@ -66,10 +66,6 @@ const MODEL_DESCRIPTIONS: Record<string, string> = {
'HumeAI TADA 1B — English speech-language model built on Llama 3.2 1B. Generates 700s+ of coherent audio with synchronized text-acoustic alignment.',
'tada-3b-ml':
'HumeAI TADA 3B Multilingual — built on Llama 3.2 3B. Supports 10 languages with high-fidelity voice cloning via text-acoustic dual alignment.',
'cosyvoice2-0.5b':
'CosyVoice2 0.5B by Alibaba. Multilingual TTS with instruct support for emotions, speed, volume, and dialects. 9 languages with zero-shot voice cloning.',
'cosyvoice3-0.5b':
'Fun-CosyVoice3 0.5B by Alibaba. Improved robustness, prosody, and Chinese dialect support over CosyVoice2. Best quality for in-the-wild speech generation.',
'whisper-base':
'Smallest Whisper model (74M parameters). Fast transcription with moderate accuracy.',
'whisper-small':
@@ -394,7 +390,14 @@ export function ModelManagement() {
setDetailOpen(true);
};
const voiceModels = modelStatus?.models.filter((m) => !m.model_name.startsWith('whisper')) ?? [];
const voiceModels =
modelStatus?.models.filter(
(m) =>
m.model_name.startsWith('qwen-tts') ||
m.model_name.startsWith('luxtts') ||
m.model_name.startsWith('chatterbox') ||
m.model_name.startsWith('tada'),
) ?? [];
const whisperModels = modelStatus?.models.filter((m) => m.model_name.startsWith('whisper')) ?? [];
// Build sections
+2 -2
View File
@@ -42,8 +42,8 @@ export interface GenerationRequest {
text: string;
language: LanguageCode;
seed?: number;
model_size?: '1.7B' | '0.6B' | '1B' | '3B' | 'v2' | 'v3';
engine?: 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo' | 'tada' | 'cosyvoice';
model_size?: '1.7B' | '0.6B' | '1B' | '3B';
engine?: 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo' | 'tada';
instruct?: string;
max_chunk_chars?: number;
crossfade_ms?: number;
-1
View File
@@ -67,7 +67,6 @@ export const ENGINE_LANGUAGES: Record<string, readonly LanguageCode[]> = {
],
chatterbox_turbo: ['en'],
tada: ['en', 'ar', 'zh', 'de', 'es', 'fr', 'it', 'ja', 'pl', 'pt'],
cosyvoice: ['zh', 'en', 'ja', 'ko', 'de', 'fr', 'ru', 'es', 'it'],
} as const;
/** Helper: get language options for a given engine. */
+8 -19
View File
@@ -15,11 +15,9 @@ const generationSchema = z.object({
text: z.string().min(1, '').max(50000),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B', '1B', '3B', 'v2', 'v3']).optional(),
modelSize: z.enum(['1.7B', '0.6B', '1B', '3B']).optional(),
instruct: z.string().max(500).optional(),
engine: z
.enum(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo', 'tada', 'cosyvoice'])
.optional(),
engine: z.enum(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo', 'tada']).optional(),
});
export type GenerationFormValues = z.infer<typeof generationSchema>;
@@ -85,11 +83,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
? data.modelSize === '3B'
? 'tada-3b-ml'
: 'tada-1b'
: engine === 'cosyvoice'
? data.modelSize === 'v3'
? 'cosyvoice3-0.5b'
: 'cosyvoice2-0.5b'
: `qwen-tts-${data.modelSize}`;
: `qwen-tts-${data.modelSize}`;
const displayName =
engine === 'luxtts'
? 'LuxTTS'
@@ -101,13 +95,9 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
? data.modelSize === '3B'
? 'TADA 3B Multilingual'
: 'TADA 1B'
: engine === 'cosyvoice'
? data.modelSize === 'v3'
? 'CosyVoice3 0.5B'
: 'CosyVoice2 0.5B'
: data.modelSize === '1.7B'
? 'Qwen TTS 1.7B'
: 'Qwen TTS 0.6B';
: data.modelSize === '1.7B'
? 'Qwen TTS 1.7B'
: 'Qwen TTS 0.6B';
// Check if model needs downloading
try {
@@ -122,7 +112,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
console.error('Failed to check model status:', error);
}
const hasModelSizes = engine === 'qwen' || engine === 'tada' || engine === 'cosyvoice';
const hasModelSizes = engine === 'qwen' || engine === 'tada';
const effectsChain = options.getEffectsChain?.();
// This now returns immediately with status="generating"
const result = await generation.mutateAsync({
@@ -132,8 +122,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
seed: data.seed,
model_size: hasModelSizes ? data.modelSize : undefined,
engine,
instruct:
engine === 'qwen' || engine === 'cosyvoice' ? data.instruct || undefined : undefined,
instruct: engine === 'qwen' ? data.instruct || undefined : undefined,
max_chunk_chars: maxChunkChars,
crossfade_ms: crossfadeMs,
normalize: normalizeAudio,
+14
View File
@@ -155,6 +155,20 @@ 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)"
+2 -30
View File
@@ -167,7 +167,6 @@ TTS_ENGINES = {
"chatterbox": "Chatterbox TTS",
"chatterbox_turbo": "Chatterbox Turbo",
"tada": "TADA",
"cosyvoice": "CosyVoice",
}
@@ -279,26 +278,6 @@ def _get_non_qwen_tts_configs() -> list[ModelConfig]:
size_mb=8000,
languages=["en", "ar", "zh", "de", "es", "fr", "it", "ja", "pl", "pt"],
),
ModelConfig(
model_name="cosyvoice2-0.5b",
display_name="CosyVoice2 0.5B (Multilingual, Instruct)",
engine="cosyvoice",
hf_repo_id="FunAudioLLM/CosyVoice2-0.5B",
model_size="v2",
size_mb=4600,
supports_instruct=True,
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "es", "it"],
),
ModelConfig(
model_name="cosyvoice3-0.5b",
display_name="CosyVoice3 0.5B (Best Quality)",
engine="cosyvoice",
hf_repo_id="FunAudioLLM/Fun-CosyVoice3-0.5B-2512",
model_size="v3",
size_mb=4600,
supports_instruct=True,
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "es", "it"],
),
]
@@ -383,7 +362,7 @@ async def load_engine_model(engine: str, model_size: str = "default") -> None:
backend = get_tts_backend_for_engine(engine)
if engine == "qwen":
await backend.load_model_async(model_size)
elif engine in ("tada", "cosyvoice"):
elif engine == "tada":
await backend.load_model(model_size)
else:
await backend.load_model()
@@ -400,7 +379,7 @@ async def ensure_model_cached_or_raise(engine: str, model_size: str = "default")
cfg = c
break
if engine in ("qwen", "tada", "cosyvoice"):
if engine in ("qwen", "tada"):
if not backend._is_model_cached(model_size):
raise HTTPException(
status_code=400,
@@ -475,9 +454,6 @@ def get_model_load_func(config: ModelConfig):
if config.engine == "qwen":
return lambda: tts.get_tts_model().load_model(config.model_size)
if config.engine in ("tada", "cosyvoice"):
return lambda: get_tts_backend_for_engine(config.engine).load_model(config.model_size)
return lambda: get_tts_backend_for_engine(config.engine).load_model()
@@ -539,10 +515,6 @@ def get_tts_backend_for_engine(engine: str) -> TTSBackend:
from .hume_backend import HumeTadaBackend
backend = HumeTadaBackend()
elif engine == "cosyvoice":
from .cosyvoice_backend import CosyVoiceTTSBackend
backend = CosyVoiceTTSBackend()
else:
raise ValueError(f"Unknown TTS engine: {engine}. Supported: {list(TTS_ENGINES.keys())}")
+31
View File
@@ -126,6 +126,37 @@ 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],
+6 -10
View File
@@ -18,6 +18,8 @@ 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,
@@ -48,7 +50,7 @@ class ChatterboxTTSBackend:
self._model_load_lock = asyncio.Lock()
def _get_device(self) -> str:
return get_torch_device(force_cpu_on_mac=True)
return get_torch_device(force_cpu_on_mac=True, allow_xpu=True)
def is_loaded(self) -> bool:
return self.model is not None
@@ -117,10 +119,7 @@ class ChatterboxTTSBackend:
del self.model
self.model = None
self._device = None
if device == "cuda":
import torch
torch.cuda.empty_cache()
empty_device_cache(device)
logger.info("Chatterbox unloaded")
async def create_voice_prompt(
@@ -200,7 +199,7 @@ class ChatterboxTTSBackend:
import torch
if seed is not None:
torch.manual_seed(seed)
manual_seed(seed, self._device)
logger.info(f"[Chatterbox] Generating: lang={language}")
@@ -220,10 +219,7 @@ 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
+6 -10
View File
@@ -18,6 +18,8 @@ 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,
@@ -48,7 +50,7 @@ class ChatterboxTurboTTSBackend:
self._model_load_lock = asyncio.Lock()
def _get_device(self) -> str:
return get_torch_device(force_cpu_on_mac=True)
return get_torch_device(force_cpu_on_mac=True, allow_xpu=True)
def is_loaded(self) -> bool:
return self.model is not None
@@ -116,10 +118,7 @@ class ChatterboxTurboTTSBackend:
del self.model
self.model = None
self._device = None
if device == "cuda":
import torch
torch.cuda.empty_cache()
empty_device_cache(device)
logger.info("Chatterbox Turbo unloaded")
async def create_voice_prompt(
@@ -181,7 +180,7 @@ class ChatterboxTurboTTSBackend:
import torch
if seed is not None:
torch.manual_seed(seed)
manual_seed(seed, self._device)
logger.info("[Chatterbox Turbo] Generating (English)")
@@ -200,10 +199,7 @@ 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
-433
View File
@@ -1,433 +0,0 @@
"""
CosyVoice2 / CosyVoice3 TTS backend implementation.
Wraps the upstream FunAudioLLM/CosyVoice library for zero-shot voice cloning
with instruct support (emotions, speed, volume, dialects). The CosyVoice repo
is cloned at setup time (``just setup-python``) and added to ``sys.path`` at
import time.
Model variants:
- CosyVoice2-0.5B: ``inference_instruct2()`` for 9-language cloning + instruct
- Fun-CosyVoice3-0.5B: improved robustness, prosody, and Chinese dialects
Both variants share a single ``cosyvoice`` engine key; the ``model_size``
field selects which HuggingFace checkpoint to download.
"""
import asyncio
import logging
import os
import sys
import threading
from pathlib import Path
from typing import ClassVar, List, Optional, Tuple
import numpy as np
from . import TTSBackend
from .base import (
is_model_cached,
get_torch_device,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
)
logger = logging.getLogger(__name__)
# ── HuggingFace repos ─────────────────────────────────────────────────
COSYVOICE_HF_REPOS = {
"v2": "FunAudioLLM/CosyVoice2-0.5B",
"v3": "FunAudioLLM/Fun-CosyVoice3-0.5B-2512",
}
# Files that must be present for CosyVoice2 / CosyVoice3
_REQUIRED_FILES = {
"v2": ["llm.pt", "flow.pt", "hift.pt", "cosyvoice2.yaml", "campplus.onnx"],
"v3": ["llm.pt", "flow.pt", "hift.pt", "cosyvoice3.yaml", "campplus.onnx"],
}
# Model name → variant key
_MODEL_NAME_TO_VARIANT = {
"cosyvoice2-0.5b": "v2",
"cosyvoice3-0.5b": "v3",
}
# Default sample rate (both models produce 24 kHz audio)
COSYVOICE_SAMPLE_RATE = 24000
def _ensure_cosyvoice_on_path() -> None:
"""Add the cloned CosyVoice repo + Matcha-TTS to sys.path if not already present."""
backend_dir = Path(__file__).resolve().parent.parent # backend/
cosyvoice_root = backend_dir / "vendors" / "CosyVoice"
if not cosyvoice_root.exists():
raise RuntimeError(
f"CosyVoice source not found at {cosyvoice_root}. "
"Run `just setup-python` to clone it."
)
cosyvoice_str = str(cosyvoice_root)
matcha_str = str(cosyvoice_root / "third_party" / "Matcha-TTS")
if cosyvoice_str not in sys.path:
sys.path.insert(0, cosyvoice_str)
if os.path.isdir(matcha_str) and matcha_str not in sys.path:
sys.path.insert(0, matcha_str)
def _shim_training_only_modules() -> None:
"""
Pre-populate ``sys.modules`` with lightweight stubs for modules that
the CosyVoice YAML configs reference but are only needed for training.
``hyperpyyaml`` resolves every ``!name:`` / ``!new:`` tag via
``pydoc.locate`` which eagerly imports the target module. The YAML
references ``cosyvoice.dataset.processor`` (12 times) which pulls in
``pyarrow``, ``pyworld``, etc. at module level.
Several ``matcha.utils.*`` submodules also import
``lightning.pytorch`` at module level. We stub those so the real
``matcha.utils`` package can still expose ``audio.py`` and ``model.py``
for inference.
"""
import types
import logging as _logging
_noop = lambda *a, **kw: None
def get_pylogger(name: str = __name__) -> _logging.Logger:
return _logging.getLogger(name)
# ── matcha.utils submodules that import lightning ──────────────
fake_pylogger = types.ModuleType("matcha.utils.pylogger")
fake_pylogger.get_pylogger = get_pylogger # type: ignore[attr-defined]
fake_logging_utils = types.ModuleType("matcha.utils.logging_utils")
fake_logging_utils.log_hyperparameters = _noop # type: ignore[attr-defined]
fake_rich_utils = types.ModuleType("matcha.utils.rich_utils")
fake_rich_utils.enforce_tags = _noop # type: ignore[attr-defined]
fake_rich_utils.print_config_tree = _noop # type: ignore[attr-defined]
fake_instantiators = types.ModuleType("matcha.utils.instantiators")
fake_instantiators.instantiate_callbacks = lambda *a, **kw: [] # type: ignore[attr-defined]
fake_instantiators.instantiate_loggers = lambda *a, **kw: [] # type: ignore[attr-defined]
fake_utils_utils = types.ModuleType("matcha.utils.utils")
fake_utils_utils.extras = _noop # type: ignore[attr-defined]
fake_utils_utils.get_metric_value = _noop # type: ignore[attr-defined]
fake_utils_utils.task_wrapper = lambda fn: fn # type: ignore[attr-defined]
sys.modules["matcha.utils.pylogger"] = fake_pylogger
sys.modules["matcha.utils.logging_utils"] = fake_logging_utils
sys.modules["matcha.utils.rich_utils"] = fake_rich_utils
sys.modules["matcha.utils.instantiators"] = fake_instantiators
sys.modules["matcha.utils.utils"] = fake_utils_utils
# ── cosyvoice.dataset.processor (training data pipeline) ──────
# Referenced 12 times in cosyvoice2.yaml / cosyvoice3.yaml via
# !name: tags. Imports pyarrow, pyworld, whisper at module level.
fake_dataset = types.ModuleType("cosyvoice.dataset")
fake_dataset.__path__ = [] # type: ignore[attr-defined]
fake_processor = types.ModuleType("cosyvoice.dataset.processor")
for _fn in (
"parquet_opener", "tokenize", "filter", "resample", "truncate",
"compute_fbank", "compute_whisper_fbank", "compute_f0",
"parse_embedding", "shuffle", "sort", "batch", "padding",
):
setattr(fake_processor, _fn, _noop)
sys.modules.setdefault("cosyvoice.dataset", fake_dataset)
sys.modules["cosyvoice.dataset.processor"] = fake_processor
def _patch_modelscope_to_hf() -> None:
"""
Monkey-patch ``modelscope.snapshot_download`` → ``huggingface_hub.snapshot_download``
so that CosyVoice's ``__init__`` downloads from HuggingFace instead of ModelScope.
Also passes ``token=None`` to avoid HF auth prompts on public repos.
"""
import types
from huggingface_hub import snapshot_download as hf_snapshot_download
def _hf_download(model_id, **kwargs):
kwargs.pop("revision", None)
kwargs.pop("model_version", None)
return hf_snapshot_download(model_id, token=None, **kwargs)
# Create a fake "modelscope" module so ``from modelscope import snapshot_download`` works.
fake_ms = types.ModuleType("modelscope")
fake_ms.snapshot_download = _hf_download
sys.modules["modelscope"] = fake_ms
def _patch_torchaudio_load() -> None:
"""
Replace ``torchaudio.load`` with a soundfile-backed implementation.
torchaudio >= 2.9 unconditionally delegates to TorchCodec and ignores
the ``backend`` parameter. CosyVoice calls ``torchaudio.load(wav,
backend='soundfile')`` which now fails unless ``torchcodec`` is
installed. We swap in a lightweight wrapper that reads via soundfile
and returns the same ``(Tensor, sample_rate)`` tuple.
"""
import torch
import torchaudio
import soundfile as sf
def _sf_load(uri, frame_offset=0, num_frames=-1, normalize=True,
channels_first=True, format=None, buffer_size=4096,
backend=None):
data, sr = sf.read(uri, start=frame_offset,
stop=None if num_frames < 0 else frame_offset + num_frames,
dtype="float32", always_2d=True)
# data shape: (frames, channels) → tensor
tensor = torch.from_numpy(data)
if channels_first:
tensor = tensor.T # (channels, frames)
return tensor, sr
torchaudio.load = _sf_load
class CosyVoiceTTSBackend:
"""CosyVoice2 / CosyVoice3 TTS backend for voice cloning with instruct support."""
# Class-level lock for import patching
_import_lock: ClassVar[threading.Lock] = threading.Lock()
_patched: ClassVar[bool] = False
def __init__(self):
self.model = None
self._variant: Optional[str] = None # "v2" or "v3"
self._device: Optional[str] = None
self._model_load_lock = asyncio.Lock()
def _get_device(self) -> str:
# CosyVoice has no MPS support — force CPU on macOS
return get_torch_device(force_cpu_on_mac=True)
def is_loaded(self) -> bool:
return self.model is not None
def _get_model_path(self, model_size: str = "v2") -> str:
return COSYVOICE_HF_REPOS.get(model_size, COSYVOICE_HF_REPOS["v2"])
def _is_model_cached(self, model_size: str = "v2") -> bool:
variant = model_size if model_size in COSYVOICE_HF_REPOS else "v2"
repo = COSYVOICE_HF_REPOS[variant]
required = _REQUIRED_FILES[variant]
return is_model_cached(repo, required_files=required)
async def load_model(self, model_size: str = "v2") -> None:
"""Load a CosyVoice model variant.
Args:
model_size: ``"v2"`` for CosyVoice2-0.5B or ``"v3"`` for CosyVoice3-0.5B.
"""
variant = model_size if model_size in COSYVOICE_HF_REPOS else "v2"
# If already loaded with the right variant, skip
if self.model is not None and self._variant == variant:
return
async with self._model_load_lock:
if self.model is not None and self._variant == variant:
return
# Unload previous variant if switching
if self.model is not None:
self.unload_model()
await asyncio.to_thread(self._load_model_sync, variant)
def _load_model_sync(self, variant: str) -> None:
"""Synchronous model loading."""
model_name = f"cosyvoice{'2' if variant == 'v2' else '3'}-0.5b"
is_cached = self._is_model_cached(variant)
with model_load_progress(model_name, is_cached):
device = self._get_device()
self._device = device
hf_repo = COSYVOICE_HF_REPOS[variant]
logger.info(
"Loading CosyVoice %s (%s) on %s...",
"2" if variant == "v2" else "3",
hf_repo,
device,
)
# 1. Ensure cosyvoice source is on sys.path
_ensure_cosyvoice_on_path()
# 2. Patch imports (thread-safe, once)
with CosyVoiceTTSBackend._import_lock:
if not CosyVoiceTTSBackend._patched:
_shim_training_only_modules()
_patch_modelscope_to_hf()
_patch_torchaudio_load()
CosyVoiceTTSBackend._patched = True
# 3. Patch torch.load to force map_location on CPU
import torch
if device == "cpu":
_orig_torch_load = torch.load
def _patched_load(*args, **kwargs):
kwargs.setdefault("map_location", "cpu")
return _orig_torch_load(*args, **kwargs)
torch.load = _patched_load
try:
if variant == "v2":
from cosyvoice.cli.cosyvoice import CosyVoice2
model = CosyVoice2(hf_repo)
else:
from cosyvoice.cli.cosyvoice import CosyVoice3
model = CosyVoice3(hf_repo)
finally:
# Restore original torch.load
if device == "cpu":
torch.load = _orig_torch_load
self.model = model
self._variant = variant
logger.info("CosyVoice %s loaded successfully", "2" if variant == "v2" else "3")
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._variant = None
self._device = None
if device == "cuda":
import torch
torch.cuda.empty_cache()
logger.info("CosyVoice unloaded")
async def create_voice_prompt(
self,
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> Tuple[dict, bool]:
"""
Create voice prompt from reference audio.
CosyVoice processes the reference at generation time via
``frontend_zero_shot`` / ``frontend_instruct2``, so we just
store the path + text for later use.
"""
voice_prompt = {
"ref_audio": str(audio_path),
"ref_text": reference_text,
}
return voice_prompt, 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,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
"""
Generate audio using CosyVoice instruct2 (with cloning) or zero-shot.
If ``instruct`` is provided, uses ``inference_instruct2()`` which
supports emotion, speed, volume, and dialect control.
Otherwise falls back to ``inference_zero_shot()``.
Args:
text: Text to synthesize.
voice_prompt: Dict with ``ref_audio`` path and ``ref_text``.
language: BCP-47 language code (unused by CosyVoice directly,
but kept for protocol compatibility).
seed: Random seed for reproducibility.
instruct: Instruct text for style control, e.g.
``"Read with a happy tone, slowly."``.
Returns:
Tuple of (audio_array, sample_rate).
"""
await self.load_model(self._variant or "v2")
ref_audio = voice_prompt.get("ref_audio")
ref_text = voice_prompt.get("ref_text", "")
if ref_audio and not Path(ref_audio).exists():
logger.warning("Reference audio not found: %s", ref_audio)
ref_audio = None
def _generate_sync():
import torch
if seed is not None:
torch.manual_seed(seed)
# Collect all chunks from the generator
audio_chunks = []
if instruct and ref_audio:
# instruct2: text + instruct + reference audio → cloned + styled
logger.info("[CosyVoice] instruct2: lang=%s instruct=%s", language, instruct[:60])
for chunk in self.model.inference_instruct2(
tts_text=text,
instruct_text=instruct,
prompt_wav=ref_audio,
stream=False,
speed=1.0,
):
audio_chunks.append(chunk["tts_speech"])
elif ref_audio:
# zero-shot voice cloning
logger.info("[CosyVoice] zero_shot: lang=%s", language)
for chunk in self.model.inference_zero_shot(
tts_text=text,
prompt_text=ref_text,
prompt_wav=ref_audio,
stream=False,
speed=1.0,
):
audio_chunks.append(chunk["tts_speech"])
else:
# cross-lingual (no reference audio, shouldn't normally happen
# in voicebox since profiles always have samples, but handle it)
logger.info("[CosyVoice] cross_lingual fallback: lang=%s", language)
for chunk in self.model.inference_cross_lingual(
tts_text=text,
prompt_wav=ref_audio or "",
stream=False,
speed=1.0,
):
audio_chunks.append(chunk["tts_speech"])
# Concatenate all chunks
if not audio_chunks:
return np.zeros(COSYVOICE_SAMPLE_RATE, dtype=np.float32), COSYVOICE_SAMPLE_RATE
full_audio = torch.cat(audio_chunks, dim=-1)
audio_np = full_audio.squeeze().cpu().numpy().astype(np.float32)
return audio_np, COSYVOICE_SAMPLE_RATE
return await asyncio.to_thread(_generate_sync)
+19 -20
View File
@@ -24,6 +24,8 @@ 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,
)
@@ -66,7 +68,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)
return get_torch_device(force_cpu_on_mac=True, allow_xpu=True)
def is_loaded(self) -> bool:
return self.model is not None
@@ -105,6 +107,7 @@ 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
@@ -142,9 +145,12 @@ class HumeTadaBackend:
allow_patterns=["tokenizer*", "special_tokens*"],
)
# Determine dtype — use bf16 on CUDA for ~50% memory savings
# Determine dtype — use bf16 on CUDA/XPU 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
@@ -153,14 +159,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).
@@ -169,12 +175,11 @@ 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}")
@@ -188,11 +193,11 @@ class HumeTadaBackend:
del self.encoder
self.encoder = None
device = self._device
self._device = None
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
if device:
empty_device_cache(device)
logger.info("HumeAI TADA unloaded")
@@ -213,9 +218,7 @@ 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)
@@ -239,9 +242,7 @@ 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 = {}
@@ -299,9 +300,7 @@ class HumeTadaBackend:
from tada.modules.encoder import EncoderOutput
if seed is not None:
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
manual_seed(seed, self._device)
device = self._device
+17 -11
View File
@@ -12,7 +12,14 @@ from typing import Optional, Tuple
import numpy as np
from . import TTSBackend
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,
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
logger = logging.getLogger(__name__)
@@ -30,7 +37,7 @@ class LuxTTSBackend:
self._device = None
def _get_device(self) -> str:
return get_torch_device(allow_mps=True)
return get_torch_device(allow_mps=True, allow_xpu=True)
def is_loaded(self) -> bool:
return self.model is not None
@@ -69,9 +76,12 @@ 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)
@@ -81,12 +91,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
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
empty_device_cache(device)
logger.info("LuxTTS unloaded")
@@ -154,12 +164,8 @@ class LuxTTSBackend:
await self.load_model()
def _generate_sync():
import torch
if seed is not None:
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
manual_seed(seed, self.device)
wav = self.model.generate_speech(
text=text,
+5 -7
View File
@@ -14,6 +14,8 @@ 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,
)
@@ -120,8 +122,7 @@ class PyTorchTTSBackend:
self.model = None
self._current_model_size = None
if torch.cuda.is_available():
torch.cuda.empty_cache()
empty_device_cache(self.device)
logger.info("TTS model unloaded")
@@ -213,9 +214,7 @@ class PyTorchTTSBackend:
"""Run synchronous generation in thread pool."""
# Set seed if provided
if seed is not None:
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
manual_seed(seed, self.device)
# Generate audio - this is the blocking operation
wavs, sample_rate = self.model.generate_voice_clone(
@@ -297,8 +296,7 @@ class PyTorchSTTBackend:
self.model = None
self.processor = None
if torch.cuda.is_available():
torch.cuda.empty_cache()
empty_device_cache(self.device)
logger.info("Whisper model unloaded")
+1 -32
View File
@@ -228,40 +228,9 @@ def build_server(cuda=False):
"torchaudio",
"--collect-submodules",
"tada",
# CosyVoice2/3 — Alibaba TTS with instruct + cloning
"--hidden-import",
"backend.backends.cosyvoice_backend",
# hyperpyyaml dynamically instantiates classes from YAML —
# needs source files and the ruamel.yaml backend
"--collect-all",
"hyperpyyaml",
# onnxruntime ships native shared libraries + provider plugins
"--collect-all",
"onnxruntime",
"--copy-metadata",
"onnxruntime",
# openai-whisper ships mel filter assets and uses tiktoken
"--collect-all",
"whisper",
"--collect-all",
"tiktoken",
# einops used by CosyVoice flow/decoder
"--hidden-import",
"einops",
]
)
# Bundle the vendored CosyVoice source tree for frozen builds.
# The clone lives at backend/vendors/CosyVoice/ at build time.
cosyvoice_vendor = backend_dir / "vendors" / "CosyVoice"
if cosyvoice_vendor.exists():
args.extend([
"--add-data",
f"{cosyvoice_vendor / 'cosyvoice'}{os.pathsep}cosyvoice",
"--add-data",
f"{cosyvoice_vendor / 'third_party' / 'Matcha-TTS' / 'matcha'}{os.pathsep}matcha",
])
# Add CUDA-specific hidden imports
if cuda:
logger.info("Building with CUDA support")
@@ -401,7 +370,7 @@ def build_server(cuda=False):
"torchvision",
"torchaudio",
"--index-url",
"https://download.pytorch.org/whl/cu126",
"https://download.pytorch.org/whl/cu128",
"--force-reinstall",
"-q",
],
+2 -2
View File
@@ -66,9 +66,9 @@ class GenerationRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=50000)
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$")
seed: Optional[int] = Field(None, ge=0)
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B|1B|3B|v2|v3)$")
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B|1B|3B)$")
instruct: Optional[str] = Field(None, max_length=500)
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo|tada|cosyvoice)$")
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo|tada)$")
max_chunk_chars: int = Field(
default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting"
)
+1 -11
View File
@@ -8,7 +8,7 @@ sqlalchemy>=2.0.0
alembic>=1.13.0
# ML models
torch>=2.1.0
torch>=2.7.0
transformers>=4.36.0,<=4.57.6
accelerate>=0.26.0
huggingface_hub>=0.20.0
@@ -40,16 +40,6 @@ pyloudnorm
# provides the only class TADA uses: Snake1d.)
torchaudio
# CosyVoice2/3 sub-dependencies (the cosyvoice source is cloned at
# setup time into backend/vendors/CosyVoice — no PyPI package exists)
hyperpyyaml>=1.2.0
onnxruntime>=1.18.0
openai-whisper>=20231117
tiktoken
einops
inflect
matplotlib
# Audio processing
librosa>=0.10.0
soundfile>=0.12.0
+9 -1
View File
@@ -110,6 +110,11 @@ 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
@@ -162,7 +167,10 @@ 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 "cpu"),
backend_variant=os.environ.get(
"VOICEBOX_BACKEND_VARIANT",
"cuda" if torch.cuda.is_available() else ("xpu" if has_xpu else "cpu"),
),
)
-5
View File
@@ -39,11 +39,6 @@ if getattr(sys, 'frozen', False):
_espeak_data = os.path.join(_meipass, 'piper_phonemize', 'espeak-ng-data')
if os.path.isdir(_espeak_data):
os.environ.setdefault('ESPEAK_DATA_PATH', _espeak_data)
# CosyVoice source + Matcha-TTS are bundled as --add-data into _MEIPASS.
# Add them to sys.path so ``from cosyvoice...`` and ``from matcha...``
# resolve at runtime.
if os.path.isdir(os.path.join(_meipass, 'cosyvoice')):
sys.path.insert(0, _meipass)
# Fast path: handle --version before any heavy imports so the Rust
# version check doesn't block for 30+ seconds loading torch etc.
+1 -1
View File
@@ -32,7 +32,7 @@ PROGRESS_KEY = "cuda-backend"
# 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_LIBS_VERSION = "cu126-v1"
CUDA_LIBS_VERSION = "cu128-v1"
def get_backends_dir() -> Path:
+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:
1. Install PyTorch with CUDA 12.6
1. Install PyTorch with CUDA 12.8
2. Build with `build_binary.py --cuda` (produces `--onedir` output)
3. Package with `scripts/package_cuda.py` into two archives:
- `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
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.
+14 -13
View File
@@ -48,12 +48,6 @@ setup-python:
{{ pip }} install --no-deps chatterbox-tts
# HumeAI TADA pins torch>=2.7,<2.8 which conflicts with our torch>=2.1
{{ pip }} install --no-deps hume-tada
# CosyVoice: clone source into backend/vendors/ (no PyPI package exists)
if [ ! -d "{{ backend_dir }}/vendors/CosyVoice" ]; then
echo "Cloning CosyVoice source..."
mkdir -p {{ backend_dir }}/vendors
git clone --recursive --depth 1 https://github.com/FunAudioLLM/CosyVoice.git {{ backend_dir }}/vendors/CosyVoice
fi
# Apple Silicon: install MLX backend
if [ "$(uname -m)" = "arm64" ] && [ "$(uname)" = "Darwin" ]; then
echo "Detected Apple Silicon — installing MLX dependencies..."
@@ -75,19 +69,26 @@ setup-python:
}
Write-Host "Installing Python dependencies..."
& "{{ python }}" -m pip install --upgrade pip -q
$hasNvidia = $null -ne (Get-WmiObject Win32_VideoController | Where-Object { $_.Name -match 'NVIDIA' })
$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
if ($hasNvidia) { \
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; \
} 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
& "{{ pip }}" install --no-deps hume-tada
if (-not (Test-Path "{{ backend_dir }}/vendors/CosyVoice")) { \
Write-Host "Cloning CosyVoice source..."; \
New-Item -ItemType Directory -Force -Path "{{ backend_dir }}/vendors" | Out-Null; \
git clone --recursive --depth 1 https://github.com/FunAudioLLM/CosyVoice.git "{{ backend_dir }}/vendors/CosyVoice"; \
}
& "{{ pip }}" install git+https://github.com/QwenLM/Qwen3-TTS.git
& "{{ pip }}" install pyinstaller ruff pytest pytest-asyncio -q
Write-Host "Python environment ready."
+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:
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
Usage:
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/ --cuda-libs-version cu126-v1
python scripts/package_cuda.py backend/dist/voicebox-server-cuda/ --cuda-libs-version cu128-v1
"""
import argparse
@@ -208,13 +208,13 @@ def main():
parser.add_argument(
"--cuda-libs-version",
type=str,
default="cu126-v1",
help="Version string for the CUDA libs archive (default: cu126-v1)",
default="cu128-v1",
help="Version string for the CUDA libs archive (default: cu128-v1)",
)
parser.add_argument(
"--torch-compat",
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)",
)
args = parser.parse_args()