Compare commits

...
Author SHA1 Message Date
James Pine 753158c1c9 fix: address review feedback — race condition, GPU safety, task GC
- Add threading lock to get_tts_backend_for_engine() to prevent race
  condition where concurrent requests could create duplicate backend
  instances (double-checked locking pattern)
- Fix LuxTTS generate: call .detach().cpu() before .numpy() so it
  works on GPU/MPS devices, not just CPU
- Store background download tasks in a module-level set to prevent
  garbage collection before completion (asyncio.create_task fire-and-
  forget pattern)
- Deduplicate cache_key computation in LuxTTS create_voice_prompt
- Prefix unused sr variable with underscore
2026-03-13 01:54:09 -07:00
James Pine 1e5afc2bef fix: LuxTTS generation and preserve model selection after generate
- Fix silent Zod validation failure when LuxTTS selected (modelSize was
  set to 'default' which failed enum validation, preventing form submit)
- Preserve engine, model size, and language after successful generation
  instead of resetting to defaults
2026-03-13 00:21:43 -07:00
James Pine 163528bf69 fix: single flat model dropdown, linacodec dep, quiet sidecar script
- Combine engine + model size into one flat dropdown (Qwen3-TTS 1.7B,
  Qwen3-TTS 0.6B, LuxTTS) in both FloatingGenerateBox and GenerationForm
- Add linacodec git dep to requirements.txt (uv-only source, pip can't
  resolve it from Zipvoice's pyproject.toml)
- Remove redundant transitive deps from requirements.txt
- Quiet the sidecar setup script (was printing misleading instructions)
2026-03-13 00:21:43 -07:00
James Pine e1ad7a6e73 fix: add piper-phonemize find-links for LuxTTS install
piper-phonemize has no PyPI wheels — needs custom find-links URL
from k2-fsa.github.io. Removed redundant transitive deps that
Zipvoice already declares.
2026-03-13 00:21:43 -07:00
James Pine 411e91bb19 docs: add just commands to README dev quick start 2026-03-13 00:21:43 -07:00
James Pine 05cf163744 chore: add justfile for streamlined dev setup and workflow
Adds 'just' as the recommended dev tool: 'just setup' for one-time
install, 'just dev' to run backend + frontend in one terminal.
Updates CONTRIBUTING.md to document just as the primary setup method.
2026-03-13 00:21:43 -07:00
James Pine d46eb5bcc6 feat: add LuxTTS as second TTS engine with multi-engine support
Introduce LuxTTS (ZipVoice) alongside Qwen TTS, enabling users to choose
between engines at generation time. LuxTTS offers fast, English-focused
voice cloning at 48kHz with ~1GB VRAM.

Backend:
- Add LuxTTSBackend with encode_prompt/generate_speech integration
- Multi-engine registry (get_tts_backend_for_engine) replacing singleton
- Engine-prefixed voice prompt cache keys to avoid collisions
- Engine field on GenerationRequest (default 'qwen' for backward compat)
- Engine dispatch in /generate and /generate/stream endpoints
- LuxTTS in model status, download, and delete maps

Frontend:
- TTS Engine selector dropdown in GenerationForm (Qwen TTS / LuxTTS)
- Conditionally hide Model Size and Delivery Instructions for LuxTTS
- Engine field added to TypeScript types and Zod schema
- LuxTTS section in Model Management page
2026-03-13 00:21:43 -07:00
Jamie PineandGitHub 6359dee406 Merge pull request #252 from jamiepine/feat/cuda-backend-swap
feat: CUDA backend swap via binary download and restart
2026-03-13 00:20:40 -07:00
James Pine a69c216794 fix: address review feedback on CUDA backend swap
- Use YAML block scalar for inline run with colons (build-cuda.yml)
- Explicitly set VOICEBOX_BACKEND_VARIANT=cpu instead of setdefault (server.py)
- Use Path.replace() for atomic move on all platforms (cuda_download.py)
- Log actual exception in checksum fetch warning (cuda_download.py)
2026-03-13 00:20:05 -07:00
James Pine 2867421550 feat: CUDA backend swap via binary download and restart
Add the ability to download a CUDA-enabled backend binary (~2.4 GB) and
swap it in via a backend-only restart, solving the #1 user pain point
(19 open 'GPU not detected' issues caused by GitHub's 2 GB asset limit).

Backend:
- cuda_download.py: download from R2 (primary) or GitHub split-parts
  (fallback), SHA-256 verification, atomic writes, progress via SSE
- 4 new endpoints: GET/POST/DELETE /backend/cuda-*, GET cuda-progress
- server.py: --version flag, auto-detect variant from binary name
- build_binary.py: --cuda flag for CUDA PyInstaller builds
- split_binary.py: split large binaries into <2GB GitHub Release assets
- CI workflow for building CUDA binary

Tauri:
- restart_server command (stop -> wait -> start)
- start_server prefers CUDA binary from {data_dir}/backends/ if present
- Version mismatch check: runs --version before launching CUDA binary

Frontend:
- GpuAcceleration component: download, progress, restart, switch, delete
- API client + types for CUDA status and management
- Platform lifecycle: restartServer() on Tauri/Web
- Aggressive 1s health polling during restart for fast reconnection
2026-03-13 00:04:12 -07:00
Jamie PineandGitHub 758577fd4b Merge pull request #238 from luminest-llc/feat/download-cancel-and-error-ui
Added download cancel/clear UI, fixed model downloading
2026-03-13 00:03:37 -07:00
Daddy Raegen a8ecf3f31d refactor: encapsulate task clearing behind TaskManager.clear_all() 2026-03-06 20:33:21 -05:00
Daddy Raegen d744e634a8 fix: address PR review feedback for download cancel/error UI
- Fix transcribe_audio to use whisper-large-v3 mapping (not openai/whisper-large)
- Propagate error field in progress-only fallback path for get_active_tasks
- Use removed return value in cancel endpoint to vary response message
- Add error rollback to handleCancel with toast on failure
- Make isCancelling per-model instead of global
- Fix inverted chevron icons in Problems panel
- Move all clears under lock in clear_all_tasks
- Simplify cancel_download to use dict.pop()
2026-03-06 10:52:57 -05:00
Daddy Raegen a362d7de2a feat: add download cancel/clear UI, fix whisper-large and error reporting
- Add cancel (X) button on downloading and errored model items
- Add collapsible Problems panel (VS Code-style) showing error details
- Add "Clear All" button to reset all stale download/error state
- Add POST /models/download/cancel endpoint to dismiss individual downloads
- Add POST /tasks/clear endpoint to reset all task and progress state
- Include error messages in /tasks/active response for visibility
- Capture SSE error messages client-side for immediate display
- Fix whisper-large using wrong HF repo (openai/whisper-large → openai/whisper-large-v3)
- Fix Whisper HF repo mapping in both PyTorch and MLX backends
- Shorten error toast to point users to Problems panel instead of wall of text
2026-03-06 00:56:14 -05:00
Jamie Pine 38bf96ff20 fix: pin numba for release CI wheel compatibility 2026-02-23 12:18:34 -08:00
Jamie Pine 0b14cb1b2c Bump version: 0.1.12 → 0.1.13 2026-02-23 11:24:46 -08:00
Jamie Pine 4d24e69012 docs: point download links to latest release 2026-02-23 11:23:30 -08:00
Jamie PineandGitHub 90436e428d Merge pull request #77 from ManuLG/fix/broken-confirmation-modals
fix: await for confirmation before deleting voices and channels
2026-02-23 11:13:12 -08:00
Jamie PineandGitHub e4bb288904 Merge pull request #93 from iJaack/fix/mlx-apple-silicon-binary
fix(mlx): bundle native libs and broaden error handling for Apple Silicon
2026-02-23 11:12:34 -08:00
Jamie PineandGitHub 6f8bc7f23b Merge pull request #95 from CelebrityPunks/fix/model-size-selection-ignored
Fix: selecting 0.6B model still downloads and uses 1.7B
2026-02-23 11:12:12 -08:00
Jamie PineandGitHub baca111d50 Merge branch 'main' into fix/model-size-selection-ignored 2026-02-23 11:12:05 -08:00
Jamie PineandGitHub cc298fe6d8 Merge pull request #79 from martyniukyurii/fix/unicode-content-disposition
fix: handle non-ASCII filenames in Content-Disposition headers
2026-02-23 11:09:36 -08:00
Jamie PineandGitHub 46b8f6b882 Merge pull request #78 from tomasmach/fix/getUserMedia-undefined-check
fix: guard getUserMedia call against undefined mediaDevices in non-secure contexts
2026-02-23 11:09:23 -08:00
Jamie PineandGitHub 162cf4fb84 Merge pull request #122 from white1107/fix/web-tailwind-plugin
fix(web): add @tailwindcss/vite plugin to web config
2026-02-21 13:46:30 -08:00
Jamie PineandGitHub 68558243d9 Merge pull request #126 from lemassykoi/main
Create requirements.txt
2026-02-21 13:46:07 -08:00
Jamie PineandGitHub 8d5ad926f9 Merge pull request #128 from mrigankad/fix/voicebox-bugs
fix: resolve multiple issues (#96, #119, #111, #108, #121, #125, #127)
2026-02-21 13:45:19 -08:00
Jamie PineandGitHub 334f037dce Merge pull request #146 from xPolar/landing/spacebot-banner
Add Spacebot banner to landing page
2026-02-21 13:41:31 -08:00
xPolar f6522eea80 Add Spacebot banner to landing page
Adds a persistent top-of-page banner linking to spacebot.sh,
another project by the creator of Voicebox. Uses existing design
tokens for a consistent look.
2026-02-21 13:37:44 -08:00
lemassykoiandAmp 7615a08f81 ci: add Windows-only build workflow without signing
Amp-Thread-ID: https://ampcode.com/threads/T-019c7c3e-072f-7109-86a7-072a6b309891
Co-authored-by: Amp <[email protected]>
2026-02-20 23:06:45 +01:00
lemassykoiandAmp 31ea3c68a5 fix: remove silent browser fallback that bypasses save dialog path
Amp-Thread-ID: https://ampcode.com/threads/T-019c7c3e-072f-7109-86a7-072a6b309891
Co-authored-by: Amp <[email protected]>
2026-02-20 19:27:41 +01:00
Mriganka 54d72ddfd0 fix: resolve multiple issues (#96, #119, #111, #108, #121, #125, #127) 2026-02-20 23:23:38 +05:30
Clément PAPPALARDOandGitHub d4794f78e1 Create requirements.txt 2026-02-20 17:14:14 +01:00
white1107 aa7c9a9a8d fix(web): add @tailwindcss/vite plugin to web config
The web version was missing the Tailwind CSS Vite plugin, causing
CSS to not load at all. This adds the same plugin configuration
that exists in the tauri version.

Fixes #121
2026-02-20 20:11:27 +09:00
AbrahamandClaude Opus 4.6 ca6ed0998a Fix model size selection ignored when generating speech
The /generate endpoint created the voice prompt before loading the
user's requested model size. Since create_voice_prompt() internally
calls load_model_async(None), it fell back to the hardcoded default
of "1.7B", causing the 1.7B model to be downloaded even when the
user explicitly selected 0.6B.

This reorders the operations so the requested model is loaded first,
ensuring create_voice_prompt() and generate() use the correct model.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-18 09:41:44 -08:00
Eva 829d4d6d5b fix(mlx): bundle native libs and broaden error handling for Apple Silicon
The distributed macOS aarch64 binary shipped without MLX acceleration despite
the model and backend code supporting it. Two root causes:

1. **OSError not caught in platform_detect.py**
   PyInstaller bundles isolate the filesystem, so when MLX tries to load its
   Metal shader libraries (.metallib) it raises OSError, not ImportError.
   platform_detect.get_backend_type() only caught ImportError, causing a
   silent fallback to PyTorch even on Apple Silicon hardware.
   Fix: broaden the except clause to (ImportError, OSError, RuntimeError)
   and import mlx.core instead of mlx (forces native lib loading eagerly).

2. **collect_data_files used instead of collect_all for MLX**
   build_binary.py and voicebox-server.spec used --collect-data /
   collect_data_files for mlx and mlx_audio. This copies Python source and
   pure-Python data, but NOT native shared libraries (.dylib, .metallib).
   Fix: switch to --collect-all / collect_all which captures binaries too,
   then pass them to Analysis(binaries=...) in the spec.

Result: macOS Apple Silicon users now get MLX inference (~4-5x faster than
PyTorch CPU), matching the performance documented in the README.
2026-02-18 16:51:48 +01:00
YuriiandCursor 0be7975db5 fix: handle non-ASCII filenames in Content-Disposition headers
The export endpoints (export-audio, export generation, export profile,
export story) crash with `'latin-1' codec can't encode characters` when
the generated text or profile/story name contains non-ASCII characters
(e.g. Cyrillic, Chinese, Arabic).

Root cause: Python's `str.isalnum()` passes Unicode letters through to
the filename, but HTTP headers are encoded as latin-1 by the ASGI server,
which cannot represent characters outside the 0-255 range.

Fix: introduce `_safe_content_disposition()` helper that builds a
standards-compliant header with an ASCII-only `filename` fallback and a
RFC 5987 `filename*=UTF-8''...` parameter for Unicode-capable clients.

Fixes #68

Co-authored-by: Cursor <[email protected]>
2026-02-17 12:58:42 +04:00
tomasmach 40e4af828a fix: guard getUserMedia call against undefined mediaDevices in non-secure contexts 2026-02-17 09:28:23 +01:00
Manuel Lorenzo 0e57826ea5 fix: await for confirmation before deleting voices and channels 2026-02-17 00:29:52 +01:00
Spacedrive Mac Mini 2 eb2cd861b1 chore: update Cargo.lock version to 0.1.12 2026-02-10 06:59:41 -08:00
Jamie PineandGitHub 701cc647a7 Merge pull request #57 from selop/chore/readme
chore: updates repo URL in README
2026-02-06 05:08:24 -08:00
Sergej Lopatkin be6ccaf044 chore: updates repo URL in README
Updates the repository URL in the README to point to the correct fork.

Adds a prerequisite for XCode on macOS for development.
2026-02-06 13:22:59 +01:00
61 changed files with 4594 additions and 442 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.1.12
current_version = 0.1.13
commit = True
tag = True
tag_name = v{new_version}
+73
View File
@@ -0,0 +1,73 @@
name: Build CUDA Backend
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
build-cuda-windows:
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
- name: Install PyTorch with CUDA 12.1
run: |
pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall --no-deps
pip install torchaudio --index-url https://download.pytorch.org/whl/cu121
- name: Verify CUDA support in torch
run: |
python -c "import torch; print(f'CUDA available in build: {torch.cuda.is_available()}'); print(f'CUDA version: {torch.version.cuda}')"
- name: Build CUDA server binary
shell: bash
working-directory: backend
run: python build_binary.py --cuda
- name: Split binary for GitHub Releases
shell: bash
run: |
python scripts/split_binary.py \
backend/dist/voicebox-server-cuda.exe \
--output release-assets/
- name: Upload split parts to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: |
release-assets/voicebox-server-cuda.part*.exe
release-assets/voicebox-server-cuda.sha256
release-assets/voicebox-server-cuda.manifest
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload binary as workflow artifact (for testing)
uses: actions/upload-artifact@v4
with:
name: voicebox-server-cuda-windows
path: backend/dist/voicebox-server-cuda.exe
retention-days: 7
# Linux CUDA build can be added later with:
# build-cuda-linux:
# runs-on: ubuntu-22.04
# ...
+63
View File
@@ -0,0 +1,63 @@
name: Build Windows
on:
workflow_dispatch:
jobs:
build-windows:
permissions:
contents: write
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
- name: Build Python server
shell: bash
run: |
cd backend
python build_binary.py
PLATFORM=$(rustc --print host-tuple)
mkdir -p ../tauri/src-tauri/binaries
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
echo "Built voicebox-server-${PLATFORM}.exe"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./tauri/src-tauri -> target"
- name: Install dependencies
run: bun install
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: tauri
tagName: v__VERSION__
releaseName: "voicebox v__VERSION__ (test build)"
releaseBody: "Test build for audio export fix"
releaseDraft: true
prerelease: true
args: ""
includeUpdaterJson: false
+20 -1
View File
@@ -32,7 +32,26 @@ Thank you for your interest in contributing to Voicebox! This document provides
### Development Setup
**Using the Makefile (recommended for macOS/Linux):** Run `make setup` to install all dependencies, then `make dev` to start development servers. See `make help` for all available commands.
**Using `just` (recommended):**
Install [just](https://github.com/casey/just) (`brew install just` or `cargo install just`), then:
```bash
just setup # creates venv, installs Python + JS deps
just dev # starts backend + desktop app in one terminal
```
Other useful commands:
```bash
just dev-web # backend + web app (no Tauri/Rust build)
just dev-backend # backend only
just kill # stop all dev processes
just clean-all # nuke everything and start fresh
just --list # see all available commands
```
**Using the Makefile:** Run `make setup` then `make dev`. See `make help` for all commands.
**Manual setup (required for Windows):**
+10 -31
View File
@@ -80,10 +80,10 @@ Voicebox is available now for macOS and Windows.
| Platform | Download |
|----------|----------|
| macOS (Apple Silicon) | [voicebox_aarch64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_aarch64.app.tar.gz) |
| macOS (Intel) | [voicebox_x64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_x64.app.tar.gz) |
| Windows (MSI) | [voicebox_0.1.0_x64_en-US.msi](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_0.1.0_x64_en-US.msi) |
| Windows (Setup) | [voicebox_0.1.0_x64-setup.exe](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_0.1.0_x64-setup.exe) |
| macOS (Apple Silicon) | [Voicebox_aarch64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/latest/download/Voicebox_aarch64.app.tar.gz) |
| macOS (Intel) | [Voicebox_x64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/latest/download/Voicebox_x64.app.tar.gz) |
| Windows (MSI) | [Latest Windows MSI](https://github.com/jamiepine/voicebox/releases/latest) |
| Windows (Setup) | [Latest Windows Setup](https://github.com/jamiepine/voicebox/releases/latest) |
> **Linux builds coming soon** — Currently blocked by GitHub runner disk space limitations.
@@ -225,42 +225,21 @@ Voicebox aims to be the **one-stop shop for everything voice** — cloning, synt
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup and contribution guidelines.
**Using the Makefile (recommended):** Run `make help` to see all available commands for setup, development, building, and testing.
### Quick Start
**With Makefile (Unix/macOS/Linux):**
```bash
# Clone the repo
git clone https://github.com/voicebox-sh/voicebox.git
git clone https://github.com/jamiepine/voicebox.git
cd voicebox
# Setup everything
make setup
# Start development
make dev
just setup # creates Python venv, installs all deps
just dev # starts backend + desktop app
```
**Manual setup (all platforms):**
Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands.
```bash
# Clone the repo
git clone https://github.com/voicebox-sh/voicebox.git
cd voicebox
Also available via Makefile: `make setup && make dev` (run `make help` for all commands).
# Install dependencies
bun install
# Install Python dependencies
cd backend && pip install -r requirements.txt && cd ..
# Start development
bun run dev
```
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org).
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [XCode on macOS](https://developer.apple.com/xcode/).
**Performance:**
- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration for 4-5x faster inference
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@voicebox/app",
"version": "0.1.12",
"version": "0.1.13",
"private": true,
"type": "module",
"scripts": {
+8 -6
View File
@@ -124,6 +124,13 @@ export function AudioTab() {
);
}
const handleChannelDelete = async (e, channelId) => {
e.stopPropagation();
if (await confirm('Delete this channel?')) {
deleteChannel.mutate(channelId);
}
}
const allChannels = channels || [];
const allDevices = devices || [];
const selectedChannel = selectedChannelId
@@ -241,12 +248,7 @@ export function AudioTab() {
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
if (confirm('Delete this channel?')) {
deleteChannel.mutate(channel.id);
}
}}
onClick={(e) => handleChannelDelete(e, channel.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -316,7 +316,7 @@ export function FloatingGenerateBox({
</span>
</div>
<AnimatePresence>
{isExpanded && (
{isExpanded && form.watch('engine') !== 'luxtts' && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
@@ -402,30 +402,41 @@ export function FloatingGenerateBox({
)}
/>
<FormField
control={form.control}
name="modelSize"
render={({ field }) => (
<FormItem className="flex-1 space-y-0">
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1.7B" className="text-xs text-muted-foreground">
Qwen3-TTS 1.7B
</SelectItem>
<SelectItem value="0.6B" className="text-xs text-muted-foreground">
Qwen3-TTS 0.6B
</SelectItem>
</SelectContent>
</Select>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
<FormItem className="flex-1 space-y-0">
<Select
value={
form.watch('engine') === 'luxtts'
? 'luxtts'
: `qwen:${form.watch('modelSize') || '1.7B'}`
}
onValueChange={(value) => {
if (value === 'luxtts') {
form.setValue('engine', 'luxtts');
} else {
const [, modelSize] = value.split(':');
form.setValue('engine', 'qwen');
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
}
}}
>
<FormControl>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="qwen:1.7B" className="text-xs text-muted-foreground">
Qwen3-TTS 1.7B
</SelectItem>
<SelectItem value="qwen:0.6B" className="text-xs text-muted-foreground">
Qwen3-TTS 0.6B
</SelectItem>
<SelectItem value="luxtts" className="text-xs text-muted-foreground">
LuxTTS
</SelectItem>
</SelectContent>
</Select>
</FormItem>
</div>
</motion.div>
</AnimatePresence>
@@ -76,29 +76,67 @@ export function GenerationForm() {
)}
/>
<FormField
control={form.control}
name="instruct"
render={({ field }) => (
<FormItem>
<FormLabel>Delivery Instructions (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormDescription>
Natural language instructions to control speech delivery (tone, emotion, pace).
Max 500 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch('engine') !== 'luxtts' && (
<FormField
control={form.control}
name="instruct"
render={({ field }) => (
<FormItem>
<FormLabel>Delivery Instructions (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormDescription>
Natural language instructions to control speech delivery (tone, emotion,
pace). Max 500 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="grid gap-4 md:grid-cols-3">
<FormItem>
<FormLabel>Model</FormLabel>
<Select
value={
form.watch('engine') === 'luxtts'
? 'luxtts'
: `qwen:${form.watch('modelSize') || '1.7B'}`
}
onValueChange={(value) => {
if (value === 'luxtts') {
form.setValue('engine', 'luxtts');
} else {
const [, modelSize] = value.split(':');
form.setValue('engine', 'qwen');
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
}
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="qwen:1.7B">Qwen3-TTS 1.7B</SelectItem>
<SelectItem value="qwen:0.6B">Qwen3-TTS 0.6B</SelectItem>
<SelectItem value="luxtts">LuxTTS</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{form.watch('engine') === 'luxtts'
? 'Fast, English-focused'
: 'Multi-language, two sizes'}
</FormDescription>
</FormItem>
<FormField
control={form.control}
name="language"
@@ -124,29 +162,6 @@ export function GenerationForm() {
)}
/>
<FormField
control={form.control}
name="modelSize"
render={({ field }) => (
<FormItem>
<FormLabel>Model Size</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1.7B">Qwen TTS 1.7B (Higher Quality)</SelectItem>
<SelectItem value="0.6B">Qwen TTS 0.6B (Faster)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Larger models produce better quality</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="seed"
@@ -170,11 +185,7 @@ export function GenerationForm() {
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isPending || !selectedProfileId}
>
<Button type="submit" className="w-full" disabled={isPending || !selectedProfileId}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -0,0 +1,387 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, Cpu, Download, Loader2, RotateCw, Trash2, Zap } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { apiClient } from '@/lib/api/client';
import type { CudaDownloadProgress } from '@/lib/api/types';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
type RestartPhase = 'idle' | 'stopping' | 'waiting' | 'ready';
export function GpuAcceleration() {
const platform = usePlatform();
const queryClient = useQueryClient();
const serverUrl = useServerStore((state) => state.serverUrl);
const { data: health } = useServerHealth();
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
const [error, setError] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Query CUDA backend status
const {
data: cudaStatus,
isLoading: cudaStatusLoading,
refetch: refetchCudaStatus,
} = useQuery({
queryKey: ['cuda-status', serverUrl],
queryFn: () => apiClient.getCudaStatus(),
refetchInterval: cudaStatusLoading ? false : 10000,
retry: 1,
enabled: !!health, // Only fetch when backend is reachable
});
// Derived state
const isCurrentlyCuda = health?.backend_variant === 'cuda';
const cudaAvailable = cudaStatus?.available ?? false;
const cudaDownloading = cudaStatus?.downloading ?? false;
// Clean up health poll on unmount
useEffect(() => {
return () => {
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
};
}, []);
// SSE progress tracking during download
useEffect(() => {
if (!cudaDownloading || !serverUrl) {
return;
}
const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as CudaDownloadProgress;
setDownloadProgress(data);
if (data.status === 'complete') {
eventSource.close();
setDownloadProgress(null);
refetchCudaStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || 'Download failed');
setDownloadProgress(null);
refetchCudaStatus();
}
} catch (e) {
console.error('Error parsing CUDA progress event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
};
return () => {
eventSource.close();
};
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
// Start aggressive health polling during restart
const startHealthPolling = useCallback(() => {
if (healthPollRef.current) return;
healthPollRef.current = setInterval(async () => {
try {
const result = await apiClient.getHealth();
if (result.status === 'healthy') {
// Server is back up
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setRestartPhase('ready');
// Invalidate all queries to refresh UI
queryClient.invalidateQueries();
// Reset after a moment
setTimeout(() => setRestartPhase('idle'), 2000);
}
} catch {
// Server still down, keep polling
}
}, 1000);
}, [queryClient]);
const handleDownload = async () => {
setError(null);
try {
await apiClient.downloadCudaBackend();
refetchCudaStatus();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to start download';
if (msg.includes('already downloaded')) {
refetchCudaStatus();
} else {
setError(msg);
}
}
};
const handleRestart = async () => {
setError(null);
setRestartPhase('stopping');
try {
setRestartPhase('waiting');
startHealthPolling();
await platform.lifecycle.restartServer();
// Invoke resolved — server is likely ready. Stop polling and refresh.
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setRestartPhase('ready');
queryClient.invalidateQueries();
setTimeout(() => setRestartPhase('idle'), 2000);
} catch (e: unknown) {
setRestartPhase('idle');
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setError(e instanceof Error ? e.message : 'Restart failed');
}
};
const handleSwitchToCpu = async () => {
// To switch to CPU: delete the CUDA binary, then restart.
// start_server always prefers CUDA if present, so we must remove it first.
setError(null);
setRestartPhase('stopping');
try {
await apiClient.deleteCudaBackend();
setRestartPhase('waiting');
startHealthPolling();
await platform.lifecycle.restartServer();
// Invoke resolved — server is likely ready
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setRestartPhase('ready');
queryClient.invalidateQueries();
setTimeout(() => setRestartPhase('idle'), 2000);
} catch (e: unknown) {
setRestartPhase('idle');
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
refetchCudaStatus();
}
};
const handleDelete = async () => {
setError(null);
try {
await apiClient.deleteCudaBackend();
refetchCudaStatus();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to delete CUDA backend');
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
};
// Don't render until health data is available
if (!health) return null;
// If the system already has native GPU (MPS, etc.), only show info - no CUDA needed
const hasNativeGpu =
health.gpu_available &&
!isCurrentlyCuda &&
health.gpu_type &&
!health.gpu_type.includes('CUDA');
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Zap className="h-4 w-4" />
GPU Acceleration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Current status */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Backend</div>
<div className="text-sm text-muted-foreground">
{isCurrentlyCuda ? 'CUDA (GPU accelerated)' : 'CPU'}
</div>
</div>
<Badge variant={isCurrentlyCuda ? 'default' : 'secondary'}>
{isCurrentlyCuda ? (
<>
<Zap className="h-3 w-3 mr-1" /> CUDA
</>
) : (
<>
<Cpu className="h-3 w-3 mr-1" /> CPU
</>
)}
</Badge>
</div>
{/* GPU info from health */}
{health.gpu_type && (
<div className="space-y-1">
<div className="text-sm font-medium">GPU</div>
<div className="text-sm text-muted-foreground">{health.gpu_type}</div>
{health.vram_used_mb != null && (
<div className="text-xs text-muted-foreground">
VRAM: {health.vram_used_mb.toFixed(0)} MB used
</div>
)}
</div>
)}
{/* Native GPU detected - no CUDA download needed */}
{hasNativeGpu && (
<div className="p-3 rounded-lg bg-accent/10 border border-accent/20">
<div className="text-sm">
Your system uses <strong>{health.gpu_type}</strong> for acceleration. No additional
downloads needed.
</div>
</div>
)}
{/* CUDA download section - only show when native GPU is NOT detected (i.e., Windows/Linux NVIDIA users) */}
{!hasNativeGpu && (
<>
{/* Download progress */}
{cudaDownloading && downloadProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{downloadProgress.filename || 'Downloading CUDA backend...'}</span>
</div>
{downloadProgress.total > 0 && (
<span className="text-muted-foreground">
{downloadProgress.progress.toFixed(1)}%
</span>
)}
</div>
{downloadProgress.total > 0 && (
<>
<Progress value={downloadProgress.progress} className="h-2" />
<div className="text-xs text-muted-foreground">
{formatBytes(downloadProgress.current)} /{' '}
{formatBytes(downloadProgress.total)}
</div>
</>
)}
</div>
)}
{/* Restart in progress */}
{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>
)}
{/* Error display */}
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Actions */}
{restartPhase === 'idle' && !cudaDownloading && (
<div className="space-y-2">
{/* Not downloaded yet - show download button */}
{!cudaAvailable && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Download the CUDA backend (~2.4 GB) for NVIDIA GPU acceleration. Requires an
NVIDIA GPU with CUDA support.
</p>
<Button onClick={handleDownload} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Download CUDA Backend
</Button>
</div>
)}
{/* Downloaded but not active - show switch button */}
{cudaAvailable && !isCurrentlyCuda && 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
acceleration.
</p>
<Button onClick={handleRestart} className="w-full" size="sm">
<RotateCw className="h-4 w-4 mr-2" />
Switch to CUDA Backend
</Button>
</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 && (
<Button
onClick={handleDelete}
variant="ghost"
className="w-full text-muted-foreground hover:text-destructive"
size="sm"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove CUDA Backend
</Button>
)}
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Download, Loader2, Trash2 } from 'lucide-react';
import { ChevronDown, ChevronUp, Download, Loader2, RotateCcw, Trash2, X } from 'lucide-react';
import { useCallback, useState } from 'react';
import {
AlertDialog,
@@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { ActiveDownloadTask } from '@/lib/api/types';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
export function ModelManagement() {
@@ -23,6 +24,9 @@ export function ModelManagement() {
const queryClient = useQueryClient();
const [downloadingModel, setDownloadingModel] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
const [consoleOpen, setConsoleOpen] = useState(false);
const [dismissedErrors, setDismissedErrors] = useState<Set<string>>(new Set());
const [localErrors, setLocalErrors] = useState<Map<string, string>>(new Map());
const { data: modelStatus, isLoading } = useQuery({
queryKey: ['modelStatus'],
@@ -35,19 +39,60 @@ export function ModelManagement() {
refetchInterval: 5000, // Refresh every 5 seconds
});
const { data: activeTasks } = useQuery({
queryKey: ['activeTasks'],
queryFn: () => apiClient.getActiveTasks(),
refetchInterval: 5000,
});
// Build a map of errored downloads for quick lookup, excluding dismissed ones
// Merge server errors with locally captured SSE errors
const erroredDownloads = new Map<string, ActiveDownloadTask>();
if (activeTasks?.downloads) {
for (const dl of activeTasks.downloads) {
if (dl.status === 'error' && !dismissedErrors.has(dl.model_name)) {
// Prefer locally captured error (from SSE) over server error
const localErr = localErrors.get(dl.model_name);
erroredDownloads.set(dl.model_name, localErr ? { ...dl, error: localErr } : dl);
}
}
}
// Also add locally captured errors that aren't in server response yet
for (const [modelName, error] of localErrors) {
if (!erroredDownloads.has(modelName) && !dismissedErrors.has(modelName)) {
erroredDownloads.set(modelName, {
model_name: modelName,
status: 'error',
started_at: new Date().toISOString(),
error,
});
}
}
const errorCount = erroredDownloads.size;
// Callbacks for download completion
const handleDownloadComplete = useCallback(() => {
console.log('[ModelManagement] Download complete, clearing state');
setDownloadingModel(null);
setDownloadingDisplayName(null);
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
}, [queryClient]);
const handleDownloadError = useCallback(() => {
console.log('[ModelManagement] Download error, clearing state');
setDownloadingModel(null);
setDownloadingDisplayName(null);
}, []);
const handleDownloadError = useCallback(
(error: string) => {
console.log('[ModelManagement] Download error, clearing state');
if (downloadingModel) {
setLocalErrors((prev) => new Map(prev).set(downloadingModel, error));
setConsoleOpen(true);
}
setDownloadingModel(null);
setDownloadingDisplayName(null);
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
},
[queryClient, downloadingModel],
);
// Use progress toast hook for the downloading model
useModelDownloadToast({
@@ -67,11 +112,17 @@ export function ModelManagement() {
const handleDownload = async (modelName: string) => {
console.log('[Download] Button clicked for:', modelName, 'at', new Date().toISOString());
// Clear any previous dismissal so fresh errors can appear
setDismissedErrors((prev) => {
const next = new Set(prev);
next.delete(modelName);
return next;
});
// Find display name
const model = modelStatus?.models.find((m) => m.model_name === modelName);
const displayName = model?.display_name || modelName;
try {
// IMPORTANT: Call the API FIRST before setting state
// Setting state enables the SSE EventSource in useModelDownloadToast,
@@ -79,14 +130,15 @@ export function ModelManagement() {
console.log('[Download] Calling download API for:', modelName);
const result = await apiClient.triggerModelDownload(modelName);
console.log('[Download] Download API responded:', result);
// NOW set state to enable SSE tracking (after download has started on backend)
setDownloadingModel(modelName);
setDownloadingDisplayName(displayName);
// Download initiated successfully - state will be cleared when SSE reports completion
// or by the polling interval detecting the model is downloaded
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
} catch (error) {
console.error('[Download] Download failed:', error);
setDownloadingModel(null);
@@ -99,6 +151,61 @@ export function ModelManagement() {
}
};
const cancelMutation = useMutation({
mutationFn: (modelName: string) => apiClient.cancelDownload(modelName),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' });
await queryClient.invalidateQueries({ queryKey: ['activeTasks'], refetchType: 'all' });
},
});
const handleCancel = (modelName: string) => {
// Snapshot previous state for rollback
const prevDismissed = dismissedErrors;
const prevLocalErrors = localErrors;
const prevDownloadingModel = downloadingModel;
const prevDownloadingDisplayName = downloadingDisplayName;
// Optimistically hide the error and suppress downloading state in UI
setDismissedErrors((prev) => new Set(prev).add(modelName));
setLocalErrors((prev) => {
const next = new Map(prev);
next.delete(modelName);
return next;
});
if (downloadingModel === modelName) {
setDownloadingModel(null);
setDownloadingDisplayName(null);
}
cancelMutation.mutate(modelName, {
onError: () => {
// Rollback optimistic updates on failure
setDismissedErrors(prevDismissed);
setLocalErrors(prevLocalErrors);
setDownloadingModel(prevDownloadingModel);
setDownloadingDisplayName(prevDownloadingDisplayName);
toast({
title: 'Cancel failed',
description: 'Could not cancel the download task.',
variant: 'destructive',
});
},
});
};
const clearAllMutation = useMutation({
mutationFn: () => apiClient.clearAllTasks(),
onSuccess: async () => {
setDismissedErrors(new Set());
setLocalErrors(new Map());
setDownloadingModel(null);
setDownloadingDisplayName(null);
await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' });
await queryClient.invalidateQueries({ queryKey: ['activeTasks'], refetchType: 'all' });
},
});
const deleteMutation = useMutation({
mutationFn: async (modelName: string) => {
console.log('[Delete] Deleting model:', modelName);
@@ -114,14 +221,11 @@ export function ModelManagement() {
});
setDeleteDialogOpen(false);
setModelToDelete(null);
// Invalidate AND explicitly refetch to ensure UI updates
// Using refetchType: 'all' ensures we refetch even if the query is stale
console.log('[Delete] Invalidating modelStatus query');
await queryClient.invalidateQueries({
await queryClient.invalidateQueries({
queryKey: ['modelStatus'],
refetchType: 'all',
});
// Also explicitly refetch to guarantee fresh data
console.log('[Delete] Explicitly refetching modelStatus query');
await queryClient.refetchQueries({ queryKey: ['modelStatus'] });
console.log('[Delete] Query refetched');
@@ -178,13 +282,47 @@ export function ModelManagement() {
});
setDeleteDialogOpen(true);
}}
onCancel={() => handleCancel(model.model_name)}
isDownloading={downloadingModel === model.model_name}
isCancelling={
cancelMutation.isPending && cancelMutation.variables === model.model_name
}
isDismissed={dismissedErrors.has(model.model_name)}
erroredDownload={erroredDownloads.get(model.model_name)}
formatSize={formatSize}
/>
))}
</div>
</div>
{/* LuxTTS Models */}
{modelStatus.models.some((m) => m.model_name.startsWith('luxtts')) && (
<div>
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">LuxTTS Models</h3>
<div className="space-y-2">
{modelStatus.models
.filter((m) => m.model_name.startsWith('luxtts'))
.map((model) => (
<ModelItem
key={model.model_name}
model={model}
onDownload={() => handleDownload(model.model_name)}
onDelete={() => {
setModelToDelete({
name: model.model_name,
displayName: model.display_name,
sizeMb: model.size_mb,
});
setDeleteDialogOpen(true);
}}
isDownloading={downloadingModel === model.model_name}
formatSize={formatSize}
/>
))}
</div>
</div>
)}
{/* Whisper Models */}
<div>
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
@@ -206,13 +344,79 @@ export function ModelManagement() {
});
setDeleteDialogOpen(true);
}}
onCancel={() => handleCancel(model.model_name)}
isDownloading={downloadingModel === model.model_name}
isCancelling={
cancelMutation.isPending && cancelMutation.variables === model.model_name
}
isDismissed={dismissedErrors.has(model.model_name)}
erroredDownload={erroredDownloads.get(model.model_name)}
formatSize={formatSize}
/>
))}
</div>
</div>
{/* Console Panel */}
{errorCount > 0 && (
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 text-xs font-medium text-muted-foreground">
<button
type="button"
onClick={() => setConsoleOpen((v) => !v)}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
{consoleOpen ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
<span>Problems</span>
<Badge variant="destructive" className="text-[10px] h-4 px-1.5 rounded-full">
{errorCount}
</Badge>
</button>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => clearAllMutation.mutate()}
disabled={clearAllMutation.isPending}
>
<RotateCcw className="h-3 w-3 mr-1" />
Clear All
</Button>
</div>
{consoleOpen && (
<div className="bg-[#1e1e1e] text-[#d4d4d4] p-3 max-h-48 overflow-auto font-mono text-xs leading-relaxed">
{Array.from(erroredDownloads.entries()).map(([modelName, dl]) => (
<div key={modelName} className="mb-2 last:mb-0">
<span className="text-[#f44747]">[error]</span>{' '}
<span className="text-[#569cd6]">{modelName}</span>
{dl.error ? (
<>
{': '}
<span className="text-[#ce9178] whitespace-pre-wrap break-all">
{dl.error}
</span>
</>
) : (
<>
{': '}
<span className="text-[#808080]">
No error details available. Try downloading again.
</span>
</>
)}
<div className="text-[#6a9955] mt-0.5">
started at {new Date(dl.started_at).toLocaleString()}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
) : null}
</CardContent>
@@ -265,23 +469,38 @@ interface ModelItemProps {
model_name: string;
display_name: string;
downloaded: boolean;
downloading?: boolean; // From server - true if download in progress
downloading?: boolean; // From server - true if download in progress
size_mb?: number;
loaded: boolean;
};
onDownload: () => void;
onDelete: () => void;
isDownloading: boolean; // Local state - true if user just clicked download
onCancel: () => void;
isDownloading: boolean; // Local state - true if user just clicked download
isCancelling: boolean;
isDismissed: boolean;
erroredDownload?: ActiveDownloadTask;
formatSize: (sizeMb?: number) => string;
}
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
function ModelItem({
model,
onDownload,
onDelete,
onCancel,
isDownloading,
isCancelling,
isDismissed,
erroredDownload,
formatSize,
}: ModelItemProps) {
// Use server's downloading state OR local state (for immediate feedback before server updates)
const showDownloading = model.downloading || isDownloading;
// Suppress downloading if user just dismissed/cancelled this model
const showDownloading = (model.downloading || isDownloading) && !erroredDownload && !isDismissed;
return (
<div className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex-1">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{model.display_name}</span>
{model.loaded && (
@@ -289,21 +508,41 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
Loaded
</Badge>
)}
{/* Only show Downloaded if actually downloaded AND not downloading */}
{model.downloaded && !model.loaded && !showDownloading && (
{model.downloaded && !model.loaded && !showDownloading && !erroredDownload && (
<Badge variant="secondary" className="text-xs">
Downloaded
</Badge>
)}
{erroredDownload && (
<Badge variant="destructive" className="text-xs">
Error
</Badge>
)}
</div>
{model.downloaded && model.size_mb && !showDownloading && (
{model.downloaded && model.size_mb && !showDownloading && !erroredDownload && (
<div className="text-xs text-muted-foreground mt-1">
Size: {formatSize(model.size_mb)}
</div>
)}
</div>
<div className="flex items-center gap-2">
{model.downloaded && !showDownloading ? (
<div className="flex items-center gap-2 shrink-0 ml-2">
{erroredDownload ? (
<div className="flex items-center gap-2">
<Button size="sm" onClick={onDownload} variant="outline">
<Download className="h-4 w-4 mr-2" />
Retry
</Button>
<Button
size="sm"
onClick={onCancel}
variant="ghost"
disabled={isCancelling}
title="Dismiss error"
>
<X className="h-4 w-4" />
</Button>
</div>
) : model.downloaded && !showDownloading ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<span>Ready</span>
@@ -319,10 +558,21 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
</Button>
</div>
) : showDownloading ? (
<Button size="sm" variant="outline" disabled>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Downloading...
</Button>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" disabled>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Downloading...
</Button>
<Button
size="sm"
onClick={onCancel}
variant="ghost"
disabled={isCancelling}
title="Cancel download"
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<Button size="sm" onClick={onDownload} variant="outline">
<Download className="h-4 w-4 mr-2" />
@@ -1,4 +1,5 @@
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration';
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
import { usePlatform } from '@/platform/PlatformContext';
@@ -11,6 +12,7 @@ export function ServerTab() {
<ConnectionForm />
<ServerStatus />
</div>
{platform.metadata.isTauri && <GpuAcceleration />}
{platform.metadata.isTauri && <UpdateStatus />}
<div className="py-8 text-center text-sm text-muted-foreground">
Created by{' '}
@@ -58,6 +58,7 @@ export function AudioSampleRecording({
// Request microphone access when component mounts
useEffect(() => {
if (!showWaveform) return;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
let stream: MediaStream | null = null;
@@ -43,7 +43,7 @@ import {
} from '@/lib/hooks/useProfiles';
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
import { useTranscription } from '@/lib/hooks/useTranscription';
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
import { convertToWav, formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
@@ -505,10 +505,23 @@ export function ProfileForm() {
language: data.language,
});
// Convert non-WAV uploads to WAV so the backend can always use soundfile.
// Recorded audio is already WAV (from useAudioRecording's convertToWav call).
let fileToUpload: File = sampleFile;
if (!sampleFile.type.includes('wav') && !sampleFile.name.toLowerCase().endsWith('.wav')) {
try {
const wavBlob = await convertToWav(sampleFile);
const wavName = sampleFile.name.replace(/\.[^.]+$/, '.wav');
fileToUpload = new File([wavBlob], wavName, { type: 'audio/wav' });
} catch {
// If browser can't decode the format, send the original and let the backend try.
}
}
try {
await addSample.mutateAsync({
profileId: profile.id,
file: sampleFile,
file: fileToUpload,
referenceText: referenceText,
});
+3 -3
View File
@@ -79,8 +79,8 @@ export function VoicesTab() {
setDialogOpen(true);
};
const handleDelete = (profileId: string) => {
if (confirm('Are you sure you want to delete this profile?')) {
const handleProfileDelete = async (profileId: string) => {
if (await confirm('Are you sure you want to delete this profile?')) {
deleteProfile.mutate(profileId);
}
};
@@ -147,7 +147,7 @@ export function VoicesTab() {
channels={channels || []}
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
onEdit={() => handleEdit(profile.id)}
onDelete={() => handleDelete(profile.id)}
onDelete={() => handleProfileDelete(profile.id)}
/>
))}
</TableBody>
+74 -31
View File
@@ -1,29 +1,30 @@
import { useServerStore } from '@/stores/serverStore';
import type { LanguageCode } from '@/lib/constants/languages';
import { useServerStore } from '@/stores/serverStore';
import type {
VoiceProfileCreate,
VoiceProfileResponse,
ProfileSampleResponse,
ActiveTasksResponse,
CudaStatus,
GenerationRequest,
GenerationResponse,
HistoryQuery,
HistoryListResponse,
HistoryResponse,
TranscriptionResponse,
HealthResponse,
ModelStatusListResponse,
HistoryListResponse,
HistoryQuery,
HistoryResponse,
ModelDownloadRequest,
ActiveTasksResponse,
ModelStatusListResponse,
ProfileSampleResponse,
StoryCreate,
StoryResponse,
StoryDetailResponse,
StoryItemBatchUpdate,
StoryItemCreate,
StoryItemDetail,
StoryItemBatchUpdate,
StoryItemReorder,
StoryItemMove,
StoryItemTrim,
StoryItemReorder,
StoryItemSplit,
StoryItemTrim,
StoryResponse,
TranscriptionResponse,
VoiceProfileCreate,
VoiceProfileResponse,
} from './types';
class ApiClient {
@@ -251,7 +252,13 @@ class ApiClient {
return response.blob();
}
async importGeneration(file: File): Promise<{ id: string; profile_id: string; profile_name: string; text: string; message: string }> {
async importGeneration(file: File): Promise<{
id: string;
profile_id: string;
profile_name: string;
text: string;
message: string;
}> {
const url = `${this.getBaseUrl()}/history/import`;
const formData = new FormData();
formData.append('file', file);
@@ -310,7 +317,12 @@ class ApiClient {
}
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
console.log('[API] triggerModelDownload called for:', modelName, 'at', new Date().toISOString());
console.log(
'[API] triggerModelDownload called for:',
modelName,
'at',
new Date().toISOString(),
);
const result = await this.request<{ message: string }>('/models/download', {
method: 'POST',
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
@@ -325,11 +337,22 @@ class ApiClient {
});
}
async cancelDownload(modelName: string): Promise<{ message: string }> {
return this.request<{ message: string }>('/models/download/cancel', {
method: 'POST',
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
});
}
// Task Management
async getActiveTasks(): Promise<ActiveTasksResponse> {
return this.request<ActiveTasksResponse>('/tasks/active');
}
async clearAllTasks(): Promise<{ message: string }> {
return this.request<{ message: string }>('/tasks/clear', { method: 'POST' });
}
// Audio Channels
async listChannels(): Promise<
Array<{
@@ -343,10 +366,7 @@ class ApiClient {
return this.request('/channels');
}
async createChannel(data: {
name: string;
device_ids: string[];
}): Promise<{
async createChannel(data: { name: string; device_ids: string[] }): Promise<{
id: string;
name: string;
is_default: boolean;
@@ -388,10 +408,7 @@ class ApiClient {
return this.request(`/channels/${channelId}/voices`);
}
async setChannelVoices(
channelId: string,
profileIds: string[],
): Promise<{ message: string }> {
async setChannelVoices(channelId: string, profileIds: string[]): Promise<{ message: string }> {
return this.request(`/channels/${channelId}/voices`, {
method: 'PUT',
body: JSON.stringify({ profile_ids: profileIds }),
@@ -402,16 +419,30 @@ class ApiClient {
return this.request(`/profiles/${profileId}/channels`);
}
async setProfileChannels(
profileId: string,
channelIds: string[],
): Promise<{ message: string }> {
async setProfileChannels(profileId: string, channelIds: string[]): Promise<{ message: string }> {
return this.request(`/profiles/${profileId}/channels`, {
method: 'PUT',
body: JSON.stringify({ channel_ids: channelIds }),
});
}
// CUDA Backend Management
async getCudaStatus(): Promise<CudaStatus> {
return this.request<CudaStatus>('/backend/cuda-status');
}
async downloadCudaBackend(): Promise<{ message: string; progress_key: string }> {
return this.request<{ message: string; progress_key: string }>('/backend/download-cuda', {
method: 'POST',
});
}
async deleteCudaBackend(): Promise<{ message: string }> {
return this.request<{ message: string }>('/backend/cuda', {
method: 'DELETE',
});
}
// Stories
async listStories(): Promise<StoryResponse[]> {
return this.request<StoryResponse[]>('/stories');
@@ -468,21 +499,33 @@ class ApiClient {
});
}
async moveStoryItem(storyId: string, itemId: string, data: StoryItemMove): Promise<StoryItemDetail> {
async moveStoryItem(
storyId: string,
itemId: string,
data: StoryItemMove,
): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async trimStoryItem(storyId: string, itemId: string, data: StoryItemTrim): Promise<StoryItemDetail> {
async trimStoryItem(
storyId: string,
itemId: string,
data: StoryItemTrim,
): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/trim`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async splitStoryItem(storyId: string, itemId: string, data: StoryItemSplit): Promise<StoryItemDetail[]> {
async splitStoryItem(
storyId: string,
itemId: string,
data: StoryItemSplit,
): Promise<StoryItemDetail[]> {
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/${itemId}/split`, {
method: 'POST',
body: JSON.stringify(data),
+26 -1
View File
@@ -34,6 +34,8 @@ export interface GenerationRequest {
language: LanguageCode;
seed?: number;
model_size?: '1.7B' | '0.6B';
engine?: 'qwen' | 'luxtts';
instruct?: string;
}
export interface GenerationResponse {
@@ -78,7 +80,29 @@ export interface HealthResponse {
model_downloaded?: boolean;
model_size?: string;
gpu_available: boolean;
gpu_type?: string;
vram_used_mb?: number;
backend_type?: string;
backend_variant?: string; // "cpu" or "cuda"
}
export interface CudaDownloadProgress {
model_name: string;
current: number;
total: number;
progress: number;
filename?: string;
status: 'downloading' | 'extracting' | 'complete' | 'error';
timestamp: string;
error?: string;
}
export interface CudaStatus {
available: boolean; // CUDA binary exists on disk
active: boolean; // Currently running the CUDA binary
binary_path?: string;
downloading: boolean; // Download in progress
download_progress?: CudaDownloadProgress;
}
export interface ModelProgress {
@@ -96,7 +120,7 @@ export interface ModelStatus {
model_name: string;
display_name: string;
downloaded: boolean;
downloading: boolean; // True if download is in progress
downloading: boolean; // True if download is in progress
size_mb?: number;
loaded: boolean;
}
@@ -113,6 +137,7 @@ export interface ActiveDownloadTask {
model_name: string;
status: string;
started_at: string;
error?: string;
}
export interface ActiveGenerationTask {
+26 -20
View File
@@ -20,11 +20,13 @@ export function useAudioRecording({
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<number | null>(null);
const startTimeRef = useRef<number | null>(null);
const cancelledRef = useRef<boolean>(false);
const startRecording = useCallback(async () => {
try {
setError(null);
chunksRef.current = [];
cancelledRef.current = false;
setDuration(0);
// Check if getUserMedia is available
@@ -87,31 +89,34 @@ export function useAudioRecording({
};
mediaRecorder.onstop = async () => {
// Snapshot the cancellation flag and recorded duration immediately —
// cancelRecording() clears chunks and sets cancelledRef synchronously
// before this async handler runs, so we must check it first.
const wasCancelled = cancelledRef.current;
const recordedDuration = startTimeRef.current
? (Date.now() - startTimeRef.current) / 1000
: undefined;
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
// Convert to WAV format to avoid needing ffmpeg on backend
try {
const wavBlob = await convertToWav(webmBlob);
// Pass the actual recorded duration
const recordedDuration = startTimeRef.current
? (Date.now() - startTimeRef.current) / 1000
: undefined;
onRecordingComplete?.(wavBlob, recordedDuration);
} catch (err) {
console.error('Error converting audio to WAV:', err);
// Fallback to original blob if conversion fails
const recordedDuration = startTimeRef.current
? (Date.now() - startTimeRef.current) / 1000
: undefined;
onRecordingComplete?.(webmBlob, recordedDuration);
}
// Stop all tracks
// Stop all tracks now that we have the data
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
// Don't fire completion callback if the recording was cancelled
if (wasCancelled) return;
// Convert to WAV format to avoid needing ffmpeg on backend
try {
const wavBlob = await convertToWav(webmBlob);
onRecordingComplete?.(wavBlob, recordedDuration);
} catch (err) {
console.error('Error converting audio to WAV:', err);
// Fallback to original blob if conversion fails
onRecordingComplete?.(webmBlob, recordedDuration);
}
};
mediaRecorder.onerror = (event) => {
@@ -167,9 +172,10 @@ export function useAudioRecording({
const cancelRecording = useCallback(() => {
if (mediaRecorderRef.current) {
cancelledRef.current = true; // Must be set before stop() triggers onstop
chunksRef.current = [];
mediaRecorderRef.current.stop();
setIsRecording(false);
chunksRef.current = [];
setDuration(0);
}
+21 -5
View File
@@ -16,6 +16,7 @@ const generationSchema = z.object({
seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B']).optional(),
instruct: z.string().max(500).optional(),
engine: z.enum(['qwen', 'luxtts']).optional(),
});
export type GenerationFormValues = z.infer<typeof generationSchema>;
@@ -47,6 +48,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
seed: undefined,
modelSize: '1.7B',
instruct: '',
engine: 'qwen',
...options.defaultValues,
},
});
@@ -67,8 +69,14 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
try {
setIsGenerating(true);
const modelName = `qwen-tts-${data.modelSize}`;
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
const engine = data.engine || 'qwen';
const modelName = engine === 'luxtts' ? 'luxtts' : `qwen-tts-${data.modelSize}`;
const displayName =
engine === 'luxtts'
? 'LuxTTS'
: data.modelSize === '1.7B'
? 'Qwen TTS 1.7B'
: 'Qwen TTS 0.6B';
try {
const modelStatus = await apiClient.getModelStatus();
@@ -87,8 +95,9 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
text: data.text,
language: data.language,
seed: data.seed,
model_size: data.modelSize,
instruct: data.instruct || undefined,
model_size: engine === 'luxtts' ? undefined : data.modelSize,
engine,
instruct: engine === 'luxtts' ? undefined : data.instruct || undefined,
});
toast({
@@ -99,7 +108,14 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const audioUrl = apiClient.getAudioUrl(result.id);
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
form.reset();
form.reset({
text: '',
language: data.language,
seed: undefined,
modelSize: data.modelSize,
instruct: '',
engine: data.engine,
});
options.onSuccess?.(result.id);
} catch (error) {
toast({
+4 -5
View File
@@ -10,7 +10,7 @@ interface UseModelDownloadToastOptions {
displayName: string;
enabled?: boolean;
onComplete?: () => void;
onError?: () => void;
onError?: (error: string) => void;
}
/**
@@ -101,7 +101,7 @@ export function useModelDownloadToast({
break;
case 'error':
statusIcon = <XCircle className="h-4 w-4 text-destructive" />;
statusText = `Error: ${progress.error || 'Unknown error'}`;
statusText = 'Download failed. See Problems panel for details.';
break;
case 'downloading':
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
@@ -131,8 +131,7 @@ export function useModelDownloadToast({
)}
</div>
),
duration: progress.status === 'complete' ? 5000 : Infinity,
variant: progress.status === 'error' ? 'destructive' : 'default',
duration: progress.status === 'complete' || progress.status === 'error' ? 5000 : Infinity,
});
// Close connection and dismiss toast on completion or error
@@ -169,7 +168,7 @@ export function useModelDownloadToast({
onComplete();
} else if (isError && onError) {
console.log('[useModelDownloadToast] Download error, calling onError callback');
onError();
onError(progress.error || 'Unknown error');
}
}
}
+36 -18
View File
@@ -22,6 +22,11 @@ export function formatAudioDuration(seconds: number): string {
* If the file has a recordedDuration property (from recording hooks),
* use that instead of trying to read metadata. This fixes issues on Windows
* where WebM files from MediaRecorder don't have proper duration metadata.
*
* For uploaded files we use AudioContext.decodeAudioData which fully decodes
* the audio and returns the exact duration. This is more reliable than
* HTMLMediaElement.duration which can return incorrect large values for VBR
* MP3 files that lack a proper XING/VBRI header.
*/
export async function getAudioDuration(
file: File & { recordedDuration?: number },
@@ -30,26 +35,39 @@ export async function getAudioDuration(
return file.recordedDuration;
}
return new Promise((resolve, reject) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
// Use Web Audio API for accurate duration — avoids VBR MP3 metadata issues.
try {
const audioContext = new AudioContext();
try {
const arrayBuffer = await file.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return audioBuffer.duration;
} finally {
await audioContext.close();
}
} catch {
// Fallback: read duration from the media element (less accurate but works for WAV).
return new Promise((resolve, reject) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.addEventListener('loadedmetadata', () => {
URL.revokeObjectURL(url);
if (Number.isFinite(audio.duration) && audio.duration > 0) {
resolve(audio.duration);
} else {
reject(new Error('Audio file has invalid duration metadata'));
}
audio.addEventListener('loadedmetadata', () => {
URL.revokeObjectURL(url);
if (Number.isFinite(audio.duration) && audio.duration > 0) {
resolve(audio.duration);
} else {
reject(new Error('Audio file has invalid duration metadata'));
}
});
audio.addEventListener('error', () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load audio file'));
});
audio.src = url;
});
audio.addEventListener('error', () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load audio file'));
});
audio.src = url;
});
}
}
/**
+1
View File
@@ -51,6 +51,7 @@ export interface PlatformAudio {
export interface PlatformLifecycle {
startServer(remote?: boolean): Promise<string>;
stopServer(): Promise<void>;
restartServer(): Promise<string>;
setKeepServerRunning(keep: boolean): Promise<void>;
setupWindowCloseHandler(): Promise<void>;
onServerReady?: () => void;
+1 -1
View File
@@ -1,3 +1,3 @@
# Backend package
__version__ = "0.1.12"
__version__ = "0.1.13"
+50 -12
View File
@@ -4,6 +4,7 @@ Backend abstraction layer for TTS and STT.
Provides a unified interface for MLX and PyTorch backends.
"""
import threading
from typing import Protocol, Optional, Tuple, List
from typing_extensions import runtime_checkable
import numpy as np
@@ -112,29 +113,65 @@ class STTBackend(Protocol):
# Global backend instances
_tts_backend: Optional[TTSBackend] = None
_tts_backends: dict[str, TTSBackend] = {}
_tts_backends_lock = threading.Lock()
_stt_backend: Optional[STTBackend] = None
# Supported TTS engines
TTS_ENGINES = {
"qwen": "Qwen TTS",
"luxtts": "LuxTTS",
}
def get_tts_backend() -> TTSBackend:
"""
Get or create TTS backend instance based on platform.
Get or create the default (Qwen) TTS backend instance based on platform.
Returns:
TTS backend instance (MLX or PyTorch)
"""
global _tts_backend
return get_tts_backend_for_engine("qwen")
def get_tts_backend_for_engine(engine: str) -> TTSBackend:
"""
Get or create a TTS backend for the given engine.
if _tts_backend is None:
backend_type = get_backend_type()
Args:
engine: Engine name ("qwen" or "luxtts")
Returns:
TTS backend instance
"""
global _tts_backends
# Fast path: check without lock
if engine in _tts_backends:
return _tts_backends[engine]
# Slow path: create with lock to avoid duplicate instantiation
with _tts_backends_lock:
# Double-check after acquiring lock
if engine in _tts_backends:
return _tts_backends[engine]
if backend_type == "mlx":
from .mlx_backend import MLXTTSBackend
_tts_backend = MLXTTSBackend()
if engine == "qwen":
backend_type = get_backend_type()
if backend_type == "mlx":
from .mlx_backend import MLXTTSBackend
backend = MLXTTSBackend()
else:
from .pytorch_backend import PyTorchTTSBackend
backend = PyTorchTTSBackend()
elif engine == "luxtts":
from .luxtts_backend import LuxTTSBackend
backend = LuxTTSBackend()
else:
from .pytorch_backend import PyTorchTTSBackend
_tts_backend = PyTorchTTSBackend()
return _tts_backend
raise ValueError(f"Unknown TTS engine: {engine}. Supported: {list(TTS_ENGINES.keys())}")
_tts_backends[engine] = backend
return backend
def get_stt_backend() -> STTBackend:
@@ -161,6 +198,7 @@ def get_stt_backend() -> STTBackend:
def reset_backends():
"""Reset backend instances (useful for testing)."""
global _tts_backend, _stt_backend
global _tts_backend, _tts_backends, _stt_backend
_tts_backend = None
_tts_backends.clear()
_stt_backend = None
+264
View File
@@ -0,0 +1,264 @@
"""
LuxTTS backend implementation.
Wraps the LuxTTS (ZipVoice) model for zero-shot voice cloning.
~1GB VRAM, 48kHz output, 150x realtime on CPU.
"""
import asyncio
import logging
from pathlib import Path
from typing import List, Optional, Tuple
import numpy as np
from . import TTSBackend
from ..utils.audio import normalize_audio, load_audio
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
from ..utils.progress import get_progress_manager
from ..utils.tasks import get_task_manager
logger = logging.getLogger(__name__)
# HuggingFace repo for model weight detection
LUXTTS_HF_REPO = "YatharthS/LuxTTS"
class LuxTTSBackend:
"""LuxTTS backend for zero-shot voice cloning."""
def __init__(self):
self.model = None
self.model_size = "default" # LuxTTS has only one model size
self._device = None
def _get_device(self) -> str:
"""Get the best available device."""
import torch
if torch.cuda.is_available():
return "cuda"
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
return "cpu"
def is_loaded(self) -> bool:
return self.model is not None
@property
def device(self) -> str:
if self._device is None:
self._device = self._get_device()
return self._device
def _get_model_path(self, model_size: str) -> str:
return LUXTTS_HF_REPO
def _is_model_cached(self, model_size: str = "default") -> bool:
"""Check if LuxTTS model weights are cached locally."""
try:
from huggingface_hub import constants as hf_constants
repo_cache = (
Path(hf_constants.HF_HUB_CACHE)
/ ("models--" + LUXTTS_HF_REPO.replace("/", "--"))
)
if not repo_cache.exists():
return False
blobs_dir = repo_cache / "blobs"
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
return False
snapshots_dir = repo_cache / "snapshots"
if snapshots_dir.exists():
has_weights = any(snapshots_dir.rglob("*.pt")) or any(
snapshots_dir.rglob("*.safetensors")
) or any(snapshots_dir.rglob("*.onnx")) or any(
snapshots_dir.rglob("*.bin")
)
return has_weights
return False
except Exception as e:
logger.warning(f"Error checking LuxTTS cache: {e}")
return False
async def load_model(self, model_size: str = "default") -> None:
"""Load the LuxTTS model."""
if self.model is not None:
return
await asyncio.to_thread(self._load_model_sync)
def _load_model_sync(self):
"""Synchronous model loading."""
progress_manager = get_progress_manager()
task_manager = get_task_manager()
model_name = "luxtts"
is_cached = self._is_model_cached()
if not is_cached:
task_manager.start_download(model_name)
progress_manager.update_progress(
model_name=model_name,
current=0,
total=0,
filename="Downloading LuxTTS model...",
status="downloading",
)
try:
from zipvoice.luxvoice import LuxTTS
device = self.device
logger.info(f"Loading LuxTTS on {device}...")
# LuxTTS constructor downloads model and loads everything
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),
)
else:
self.model = LuxTTS(
model_path=LUXTTS_HF_REPO,
device=device,
)
if not is_cached:
progress_manager.mark_complete(model_name)
task_manager.complete_download(model_name)
logger.info("LuxTTS loaded successfully")
except Exception as e:
logger.error(f"Failed to load LuxTTS: {e}")
if not is_cached:
progress_manager.mark_error(model_name, str(e))
task_manager.error_download(model_name, str(e))
raise
def unload_model(self) -> None:
"""Unload model to free memory."""
if self.model is not None:
del self.model
self.model = None
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
logger.info("LuxTTS 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.
LuxTTS uses its own encode_prompt() which runs Whisper ASR internally
to transcribe the reference. The reference_text parameter is not used
by LuxTTS itself, but we include it in the cache key for consistency.
"""
await self.load_model()
# Compute cache key once for both lookup and storage
cache_key = ("luxtts_" + get_cache_key(audio_path, reference_text)) if use_cache else None
if cache_key:
cached = get_cached_voice_prompt(cache_key)
if cached is not None and isinstance(cached, dict):
return cached, True
def _encode_sync():
return self.model.encode_prompt(
prompt_audio=str(audio_path),
duration=5,
rms=0.01,
)
encoded = await asyncio.to_thread(_encode_sync)
if cache_key:
cache_voice_prompt(cache_key, encoded)
return encoded, False
async def combine_voice_prompts(
self,
audio_paths: List[str],
reference_texts: List[str],
) -> Tuple[np.ndarray, str]:
"""
Combine multiple reference samples.
LuxTTS doesn't have native multi-prompt support, so we concatenate
the audio and let encode_prompt handle the combined clip.
"""
combined_audio = []
for path in audio_paths:
audio, _sr = load_audio(path, sample_rate=24000)
audio = normalize_audio(audio)
combined_audio.append(audio)
mixed = np.concatenate(combined_audio)
mixed = normalize_audio(mixed)
combined_text = " ".join(reference_texts)
return mixed, combined_text
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 from text using LuxTTS.
Args:
text: Text to synthesize
voice_prompt: Encoded prompt dict from encode_prompt()
language: Language code (LuxTTS is English-focused)
seed: Random seed for reproducibility
instruct: Not supported by LuxTTS (ignored)
Returns:
Tuple of (audio_array, sample_rate)
"""
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)
wav = self.model.generate_speech(
text=text,
encode_dict=voice_prompt,
num_steps=4,
guidance_scale=3.0,
t_shift=0.5,
speed=1.0,
return_smooth=False, # 48kHz output
)
# LuxTTS returns a tensor (may be on GPU/MPS), move to CPU first
audio = wav.detach().cpu().numpy().squeeze()
return audio, 48000
return await asyncio.to_thread(_generate_sync)
+12 -4
View File
@@ -379,9 +379,17 @@ class MLXTTSBackend:
return audio, sample_rate
WHISPER_HF_REPOS = {
"base": "openai/whisper-base",
"small": "openai/whisper-small",
"medium": "openai/whisper-medium",
"large": "openai/whisper-large-v3",
}
class MLXSTTBackend:
"""MLX-based STT backend using mlx-audio Whisper."""
def __init__(self, model_size: str = "base"):
self.model = None
self.model_size = model_size
@@ -402,8 +410,8 @@ class MLXSTTBackend:
"""
try:
from huggingface_hub import constants as hf_constants
model_name = f"openai/whisper-{model_size}"
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + hf_repo.replace("/", "--"))
if not repo_cache.exists():
return False
@@ -474,7 +482,7 @@ class MLXSTTBackend:
from mlx_audio.stt import load
# MLX Whisper uses the standard OpenAI models
model_name = f"openai/whisper-{model_size}"
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
print(f"Loading MLX Whisper model {model_size}...")
+65 -20
View File
@@ -29,9 +29,23 @@ class PyTorchTTSBackend:
"""Get the best available device."""
if torch.cuda.is_available():
return "cuda"
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
# MPS can have issues, use CPU for stability
return "cpu"
# Intel Arc / Intel Xe GPU via intel-extension-for-pytorch (IPEX)
try:
import intel_extension_for_pytorch # noqa: F401
if hasattr(torch, 'xpu') and torch.xpu.is_available():
return "xpu"
except ImportError:
pass
# Any GPU on Windows via DirectML (torch-directml)
try:
import torch_directml
if torch_directml.device_count() > 0:
return torch_directml.device(0)
except ImportError:
pass
# MPS (Apple Silicon) — kept for completeness but MLX backend is preferred
if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
return "cpu" # MPS disabled for stability; MLX backend handles Apple Silicon
return "cpu"
def is_loaded(self) -> bool:
@@ -166,11 +180,21 @@ class PyTorchTTSBackend:
# Load the model (tqdm is patched, but filters out non-download progress)
try:
self.model = Qwen3TTSModel.from_pretrained(
model_path,
device_map=self.device,
torch_dtype=torch.float32 if self.device == "cpu" else torch.bfloat16,
)
# Don't pass device_map on CPU: accelerate's meta-tensor mechanism
# causes "Cannot copy out of meta tensor" when moving to CPU.
# Instead load directly then call .to(device) if needed.
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,
)
finally:
# Exit the patch context
tracker_context.__exit__(None, None, None)
@@ -345,9 +369,17 @@ class PyTorchTTSBackend:
return audio, sample_rate
WHISPER_HF_REPOS = {
"base": "openai/whisper-base",
"small": "openai/whisper-small",
"medium": "openai/whisper-medium",
"large": "openai/whisper-large-v3",
}
class PyTorchSTTBackend:
"""PyTorch-based STT backend using Whisper."""
def __init__(self, model_size: str = "base"):
self.model = None
self.processor = None
@@ -358,9 +390,22 @@ class PyTorchSTTBackend:
"""Get the best available device."""
if torch.cuda.is_available():
return "cuda"
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
# MPS support for Whisper
return "cpu" # Use CPU for stability
# Intel Arc / Intel Xe GPU via intel-extension-for-pytorch (IPEX)
try:
import intel_extension_for_pytorch # noqa: F401
if hasattr(torch, 'xpu') and torch.xpu.is_available():
return "xpu"
except ImportError:
pass
# Any GPU on Windows via DirectML (torch-directml)
try:
import torch_directml
if torch_directml.device_count() > 0:
return torch_directml.device(0)
except ImportError:
pass
if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
return "cpu" # MPS disabled for stability
return "cpu"
def is_loaded(self) -> bool:
@@ -379,18 +424,18 @@ class PyTorchSTTBackend:
"""
try:
from huggingface_hub import constants as hf_constants
model_name = f"openai/whisper-{model_size}"
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + hf_repo.replace("/", "--"))
if not repo_cache.exists():
return False
# Check for .incomplete files - if any exist, download is still in progress
blobs_dir = repo_cache / "blobs"
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
print(f"[_is_model_cached] Found .incomplete files for whisper-{model_size}, treating as not cached")
return False
# Check that actual model weight files exist in snapshots
snapshots_dir = repo_cache / "snapshots"
if snapshots_dir.exists():
@@ -401,12 +446,12 @@ class PyTorchSTTBackend:
if not has_weights:
print(f"[_is_model_cached] No model weights found for whisper-{model_size}, treating as not cached")
return False
return True
except Exception as e:
print(f"[_is_model_cached] Error checking cache for whisper-{model_size}: {e}")
return False
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the Whisper model.
@@ -457,7 +502,7 @@ class PyTorchSTTBackend:
# Import transformers
from transformers import WhisperProcessor, WhisperForConditionalGeneration
model_name = f"openai/whisper-{model_size}"
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
print(f"[DEBUG] Model name: {model_name}")
print(f"Loading Whisper model {model_size} on {self.device}...")
+43 -11
View File
@@ -1,8 +1,13 @@
"""
PyInstaller build script for creating standalone Python server binary.
Usage:
python build_binary.py # Build default (CPU) server binary
python build_binary.py --cuda # Build CUDA-enabled server binary
"""
import PyInstaller.__main__
import argparse
import os
import platform
from pathlib import Path
@@ -13,15 +18,22 @@ def is_apple_silicon():
return platform.system() == "Darwin" and platform.machine() == "arm64"
def build_server():
"""Build Python server as standalone binary."""
def build_server(cuda=False):
"""Build Python server as standalone binary.
Args:
cuda: If True, build with CUDA support and name the binary
voicebox-server-cuda instead of voicebox-server.
"""
backend_dir = Path(__file__).parent
binary_name = 'voicebox-server-cuda' if cuda else 'voicebox-server'
# PyInstaller arguments
args = [
'server.py', # Use server.py as entry point instead of main.py
'--onefile',
'--name', 'voicebox-server',
'--name', binary_name,
]
# Add local qwen_tts path if specified (for editable installs)
@@ -49,6 +61,7 @@ def build_server():
'--hidden-import', 'backend.utils.progress',
'--hidden-import', 'backend.utils.hf_progress',
'--hidden-import', 'backend.utils.validation',
'--hidden-import', 'backend.cuda_download',
'--hidden-import', 'torch',
'--hidden-import', 'transformers',
'--hidden-import', 'fastapi',
@@ -70,8 +83,16 @@ def build_server():
'--collect-submodules', 'jaraco',
])
# Add MLX-specific imports if building on Apple Silicon
if is_apple_silicon():
# Add CUDA-specific hidden imports
if cuda:
print("Building with CUDA support")
args.extend([
'--hidden-import', 'torch.cuda',
'--hidden-import', 'torch.backends.cudnn',
])
# Add MLX-specific imports if building on Apple Silicon (never for CUDA builds)
if is_apple_silicon() and not cuda:
print("Building for Apple Silicon - including MLX dependencies")
args.extend([
'--hidden-import', 'backend.backends.mlx_backend',
@@ -83,11 +104,15 @@ def build_server():
'--hidden-import', 'mlx_audio.stt',
'--collect-submodules', 'mlx',
'--collect-submodules', 'mlx_audio',
# Collect MLX data files including Metal shader libraries (.metallib)
'--collect-data', 'mlx',
'--collect-data', 'mlx_audio',
# Use --collect-all so PyInstaller bundles both data files AND
# native shared libraries (.dylib, .metallib) for MLX.
# Previously only --collect-data was used, which caused MLX to
# raise OSError at runtime inside the bundled binary because
# the Metal shader libraries were missing.
'--collect-all', 'mlx',
'--collect-all', 'mlx_audio',
])
else:
elif not cuda:
print("Building for non-Apple Silicon platform - PyTorch only")
args.extend([
@@ -101,8 +126,15 @@ def build_server():
# Run PyInstaller
PyInstaller.__main__.run(args)
print(f"Binary built in {backend_dir / 'dist' / 'voicebox-server'}")
print(f"Binary built in {backend_dir / 'dist' / binary_name}")
if __name__ == '__main__':
build_server()
parser = argparse.ArgumentParser(description="Build voicebox-server binary")
parser.add_argument(
'--cuda',
action='store_true',
help="Build CUDA-enabled binary (voicebox-server-cuda)",
)
cli_args = parser.parse_args()
build_server(cuda=cli_args.cuda)
+9
View File
@@ -4,8 +4,17 @@ Configuration module for voicebox backend.
Handles data directory configuration for production bundling.
"""
import os
from pathlib import Path
# Allow users to override the HuggingFace model download directory.
# Set VOICEBOX_MODELS_DIR to an absolute path before starting the server.
# This sets HF_HUB_CACHE so all huggingface_hub downloads go to that path.
_custom_models_dir = os.environ.get("VOICEBOX_MODELS_DIR")
if _custom_models_dir:
os.environ["HF_HUB_CACHE"] = _custom_models_dir
print(f"[config] Model download path set to: {_custom_models_dir}")
# Default data directory (used in development)
_data_dir = Path("data")
+198
View File
@@ -0,0 +1,198 @@
"""
CUDA backend binary download, assembly, and verification.
Downloads split parts of the CUDA-enabled voicebox-server binary from
GitHub Releases, reassembles them, verifies integrity via SHA-256,
and places the binary in the app's data directory for use on next
backend restart.
"""
import hashlib
import logging
import os
import sys
from pathlib import Path
from typing import Optional
from .config import get_data_dir
from .utils.progress import get_progress_manager
from . import __version__
logger = logging.getLogger(__name__)
GITHUB_RELEASES_URL = "https://github.com/jamiepine/voicebox/releases/download"
PROGRESS_KEY = "cuda-backend"
def get_backends_dir() -> Path:
"""Directory where downloaded backend binaries are stored."""
d = get_data_dir() / "backends"
d.mkdir(parents=True, exist_ok=True)
return d
def get_cuda_binary_name() -> str:
"""Platform-specific CUDA binary filename."""
if sys.platform == "win32":
return "voicebox-server-cuda.exe"
return "voicebox-server-cuda"
def get_cuda_binary_path() -> Optional[Path]:
"""Return path to CUDA binary if it exists."""
p = get_backends_dir() / get_cuda_binary_name()
if p.exists():
return p
return None
def is_cuda_active() -> bool:
"""Check if the current process is the CUDA binary.
The CUDA binary sets this env var on startup (see server.py).
"""
return os.environ.get("VOICEBOX_BACKEND_VARIANT") == "cuda"
def get_cuda_status() -> dict:
"""Get current CUDA backend status for the API."""
progress_manager = get_progress_manager()
cuda_path = get_cuda_binary_path()
progress = progress_manager.get_progress(PROGRESS_KEY)
return {
"available": cuda_path is not None,
"active": is_cuda_active(),
"binary_path": str(cuda_path) if cuda_path else None,
"downloading": progress is not None and progress.get("status") == "downloading",
"download_progress": progress,
}
async def download_cuda_binary(version: Optional[str] = None):
"""Download the CUDA backend binary from GitHub Releases.
Downloads split parts listed in a manifest file, concatenates them,
and verifies the SHA-256 checksum for integrity. Atomic write
(temp file -> rename).
Args:
version: Version tag (e.g. "v0.2.0"). Defaults to current app version.
"""
import httpx
if version is None:
version = f"v{__version__}"
progress = get_progress_manager()
binary_name = get_cuda_binary_name()
dest_dir = get_backends_dir()
final_path = dest_dir / binary_name
temp_path = dest_dir / f"{binary_name}.download"
# Clean up any leftover partial download
if temp_path.exists():
temp_path.unlink()
logger.info(f"Starting CUDA backend download for {version}")
progress.update_progress(
PROGRESS_KEY, current=0, total=0,
filename="Fetching manifest...", status="downloading",
)
base_url = f"{GITHUB_RELEASES_URL}/{version}"
stem = Path(binary_name).stem # voicebox-server-cuda
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
# Fetch the manifest (list of split part filenames)
manifest_url = f"{base_url}/{stem}.manifest"
manifest_resp = await client.get(manifest_url)
manifest_resp.raise_for_status()
parts = [p.strip() for p in manifest_resp.text.strip().splitlines() if p.strip()]
if not parts:
raise ValueError("Empty manifest — no split parts found")
logger.info(f"Found {len(parts)} split parts to download")
# Fetch expected checksum (optional — for integrity verification)
expected_sha = None
try:
sha_url = f"{base_url}/{stem}.sha256"
sha_resp = await client.get(sha_url)
if sha_resp.status_code == 200:
# Format: "sha256hex filename\n"
expected_sha = sha_resp.text.strip().split()[0]
logger.info(f"Expected SHA-256: {expected_sha[:16]}...")
except Exception as e:
logger.warning(f"Could not fetch checksum file — skipping verification: {e}")
# Download and concatenate parts
total_downloaded = 0
with open(temp_path, "wb") as f:
for i, part_name in enumerate(parts):
part_url = f"{base_url}/{part_name}"
logger.info(f"Downloading part {i + 1}/{len(parts)}: {part_name}")
async with client.stream("GET", part_url) as response:
response.raise_for_status()
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
f.write(chunk)
total_downloaded += len(chunk)
progress.update_progress(
PROGRESS_KEY, current=total_downloaded, total=0,
filename=f"Part {i + 1}/{len(parts)}",
status="downloading",
)
# Verify integrity if checksum was available
if expected_sha:
progress.update_progress(
PROGRESS_KEY, current=total_downloaded, total=total_downloaded,
filename="Verifying integrity...", status="downloading",
)
sha256 = hashlib.sha256()
with open(temp_path, "rb") as f:
while True:
chunk = f.read(1024 * 1024)
if not chunk:
break
sha256.update(chunk)
actual = sha256.hexdigest()
if actual != expected_sha:
raise ValueError(
f"Integrity check failed: expected {expected_sha[:16]}..., "
f"got {actual[:16]}..."
)
logger.info(f"Integrity verified: {actual[:16]}...")
# Atomic move into place (replace handles existing target on all platforms)
temp_path.replace(final_path)
# Make executable on Unix
if sys.platform != "win32":
final_path.chmod(0o755)
logger.info(f"CUDA backend downloaded to {final_path}")
progress.mark_complete(PROGRESS_KEY)
except Exception as e:
# Clean up on failure
if temp_path.exists():
temp_path.unlink()
logger.error(f"CUDA backend download failed: {e}")
progress.mark_error(PROGRESS_KEY, str(e))
raise
async def delete_cuda_binary() -> bool:
"""Delete the downloaded CUDA binary. Returns True if deleted."""
path = get_cuda_binary_path()
if path and path.exists():
path.unlink()
logger.info(f"Deleted CUDA binary: {path}")
return True
return False
+345 -36
View File
@@ -22,6 +22,24 @@ import uuid
import asyncio
import signal
import os
from urllib.parse import quote
def _safe_content_disposition(disposition_type: str, filename: str) -> str:
"""Build a Content-Disposition header that is safe for non-ASCII filenames.
Uses RFC 5987 ``filename*`` parameter so that browsers can decode
UTF-8 filenames while the ``filename`` fallback stays ASCII-only.
"""
ascii_name = "".join(
c for c in filename if c.isascii() and (c.isalnum() or c in " -_.")
).strip() or "download"
utf8_name = quote(filename, safe="")
return (
f'{disposition_type}; filename="{ascii_name}"; '
f"filename*=UTF-8''{utf8_name}"
)
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
@@ -30,6 +48,18 @@ from .utils.tasks import get_task_manager
from .utils.cache import clear_voice_prompt_cache
from .platform_detect import get_backend_type
# Keep references to fire-and-forget background tasks to prevent GC
_background_tasks: set = set()
def _create_background_task(coro) -> asyncio.Task:
"""Create a background task and prevent it from being garbage collected."""
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
return task
app = FastAPI(
title="voicebox API",
description="Production-quality Qwen3-TTS voice cloning API",
@@ -77,10 +107,39 @@ async def health():
tts_model = tts.get_tts_model()
backend_type = get_backend_type()
# Check for GPU availability (CUDA or MPS)
# Check for GPU availability (CUDA, MPS, Intel Arc XPU, or DirectML)
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
# Intel Arc / Intel Xe via intel-extension-for-pytorch (IPEX)
has_xpu = False
xpu_name = None
try:
import intel_extension_for_pytorch as ipex # noqa: F401
if hasattr(torch, 'xpu') and torch.xpu.is_available():
has_xpu = True
try:
xpu_name = torch.xpu.get_device_name(0)
except Exception:
xpu_name = "Intel GPU"
except ImportError:
pass
# DirectML backend (torch-directml) for any Windows GPU
has_directml = False
directml_name = None
try:
import torch_directml
if torch_directml.device_count() > 0:
has_directml = True
try:
directml_name = torch_directml.device_name(0)
except Exception:
directml_name = "DirectML GPU"
except ImportError:
pass
gpu_available = has_cuda or has_mps or has_xpu or has_directml or backend_type == "mlx"
gpu_type = None
if has_cuda:
@@ -89,6 +148,10 @@ async def health():
gpu_type = "MPS (Apple Silicon)"
elif backend_type == "mlx":
gpu_type = "Metal (Apple Silicon via MLX)"
elif has_xpu:
gpu_type = f"XPU ({xpu_name})"
elif has_directml:
gpu_type = f"DirectML ({directml_name})"
vram_used = None
if has_cuda:
@@ -155,6 +218,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", "cpu"),
)
@@ -252,12 +316,17 @@ async def add_profile_sample(
db: Session = Depends(get_db),
):
"""Add a sample to a voice profile."""
# Save uploaded file to temporary location
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
# Preserve the uploaded file's extension so librosa can detect format correctly.
# Defaulting to .wav was causing soundfile to reject MP3/WebM content as invalid WAV.
_allowed_audio_exts = {'.wav', '.mp3', '.m4a', '.ogg', '.flac', '.aac', '.webm', '.opus'}
_uploaded_ext = Path(file.filename or '').suffix.lower()
file_suffix = _uploaded_ext if _uploaded_ext in _allowed_audio_exts else '.wav'
with tempfile.NamedTemporaryFile(suffix=file_suffix, delete=False) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
try:
sample = await profiles.add_profile_sample(
profile_id,
@@ -268,6 +337,8 @@ async def add_profile_sample(
return sample
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to process audio file: {str(e)}")
finally:
# Clean up temp file
Path(tmp_path).unlink(missing_ok=True)
@@ -388,7 +459,7 @@ async def export_profile(
io.BytesIO(zip_bytes),
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
"Content-Disposition": _safe_content_disposition("attachment", filename)
}
)
except ValueError as e:
@@ -542,25 +613,18 @@ async def generate_speech(
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
# Create voice prompt from profile
voice_prompt = await profiles.create_voice_prompt_for_profile(
data.profile_id,
db,
)
# Generate audio
tts_model = tts.get_tts_model()
# Load the requested model size if different from current (async to not block)
from .backends import get_tts_backend_for_engine
engine = data.engine or "qwen"
tts_model = get_tts_backend_for_engine(engine)
# Resolve model size (only relevant for Qwen engine)
model_size = data.model_size or "1.7B"
# Check if model needs to be downloaded first
model_path = tts_model._get_model_path(model_size)
if model_path.startswith("Qwen/"):
# Model not cached - check if it exists remotely or needs download
from huggingface_hub import constants as hf_constants
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
if not repo_cache.exists():
# Start download in background
if engine == "qwen":
if not tts_model._is_model_cached(model_size):
model_name = f"qwen-tts-{model_size}"
async def download_model_background():
@@ -570,19 +634,51 @@ async def generate_speech(
task_manager.error_download(model_name, str(e))
task_manager.start_download(model_name)
asyncio.create_task(download_model_background())
_create_background_task(download_model_background())
# Return 202 Accepted with download info
raise HTTPException(
status_code=202,
detail={
"message": f"Model {model_size} is being downloaded. Please wait and try again.",
"model_name": model_name,
"downloading": True
}
"downloading": True,
},
)
await tts_model.load_model_async(model_size)
# Load (or switch to) the requested model
await tts_model.load_model_async(model_size)
elif engine == "luxtts":
if not tts_model._is_model_cached():
model_name = "luxtts"
async def download_luxtts_background():
try:
await tts_model.load_model()
except Exception as e:
task_manager.error_download(model_name, str(e))
task_manager.start_download(model_name)
_create_background_task(download_luxtts_background())
raise HTTPException(
status_code=202,
detail={
"message": "LuxTTS model is being downloaded. Please wait and try again.",
"model_name": model_name,
"downloading": True,
},
)
await tts_model.load_model()
# Create voice prompt from profile
voice_prompt = await profiles.create_voice_prompt_for_profile(
data.profile_id,
db,
use_cache=True,
engine=engine,
)
audio, sample_rate = await tts_model.generate(
data.text,
voice_prompt,
@@ -625,6 +721,70 @@ async def generate_speech(
raise HTTPException(status_code=500, detail=str(e))
@app.post("/generate/stream")
async def stream_speech(
data: models.GenerationRequest,
db: Session = Depends(get_db),
):
"""
Generate speech and stream the WAV audio directly without saving to disk.
Returns raw WAV bytes via a StreamingResponse so the client can start
playing audio before the entire file has been received. This endpoint
does NOT create a history entry — use /generate for that.
"""
from .backends import get_tts_backend_for_engine
profile = await profiles.get_profile(data.profile_id, db)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
engine = data.engine or "qwen"
tts_model = get_tts_backend_for_engine(engine)
model_size = data.model_size or "1.7B"
if engine == "qwen":
if not tts_model._is_model_cached(model_size):
raise HTTPException(
status_code=400,
detail=f"Model {model_size} is not downloaded yet. Use /generate to trigger a download.",
)
await tts_model.load_model_async(model_size)
elif engine == "luxtts":
if not tts_model._is_model_cached():
raise HTTPException(
status_code=400,
detail="LuxTTS model is not downloaded yet. Use /generate to trigger a download.",
)
await tts_model.load_model()
voice_prompt = await profiles.create_voice_prompt_for_profile(
data.profile_id, db, engine=engine,
)
audio, sample_rate = await tts_model.generate(
data.text,
voice_prompt,
data.language,
data.seed,
data.instruct,
)
wav_bytes = tts.audio_to_wav_bytes(audio, sample_rate)
async def _wav_stream():
# Yield in chunks so large responses don't block the event loop
chunk_size = 64 * 1024 # 64 KB
for i in range(0, len(wav_bytes), chunk_size):
yield wav_bytes[i : i + chunk_size]
return StreamingResponse(
_wav_stream(),
media_type="audio/wav",
headers={"Content-Disposition": 'attachment; filename="speech.wav"'},
)
# ============================================
# HISTORY ENDPOINTS
# ============================================
@@ -753,7 +913,7 @@ async def export_generation(
io.BytesIO(zip_bytes),
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
"Content-Disposition": _safe_content_disposition("attachment", filename)
}
)
except ValueError as e:
@@ -786,7 +946,7 @@ async def export_generation_audio(
audio_path,
media_type="audio/wav",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
"Content-Disposition": _safe_content_disposition("attachment", filename)
}
)
@@ -818,7 +978,11 @@ async def transcribe_audio(
# Check if Whisper model is downloaded (uses default size "base")
model_size = whisper_model.model_size
model_name = f"openai/whisper-{model_size}"
# Map model sizes to HF repo IDs (whisper-large needs -v3 suffix)
whisper_hf_repos = {
"large": "openai/whisper-large-v3",
}
model_name = whisper_hf_repos.get(model_size, f"openai/whisper-{model_size}")
# Check if model is cached
from huggingface_hub import constants as hf_constants
@@ -834,7 +998,7 @@ async def transcribe_audio(
get_task_manager().error_download(progress_model_name, str(e))
get_task_manager().start_download(progress_model_name)
asyncio.create_task(download_whisper_background())
_create_background_task(download_whisper_background())
# Return 202 Accepted
raise HTTPException(
@@ -1054,7 +1218,7 @@ async def export_story_audio(
io.BytesIO(audio_bytes),
media_type="audio/wav",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
"Content-Disposition": _safe_content_disposition("attachment", filename)
}
)
except HTTPException:
@@ -1196,15 +1360,24 @@ async def get_model_status():
whisper_base_id = "openai/whisper-base"
whisper_small_id = "openai/whisper-small"
whisper_medium_id = "openai/whisper-medium"
whisper_large_id = "openai/whisper-large"
whisper_large_id = "openai/whisper-large-v3"
else:
tts_1_7b_id = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
tts_0_6b_id = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
whisper_base_id = "openai/whisper-base"
whisper_small_id = "openai/whisper-small"
whisper_medium_id = "openai/whisper-medium"
whisper_large_id = "openai/whisper-large"
whisper_large_id = "openai/whisper-large-v3"
# Check if LuxTTS backend is loaded
def check_luxtts_loaded():
try:
from .backends import get_tts_backend_for_engine
backend = get_tts_backend_for_engine("luxtts")
return backend.is_loaded()
except Exception:
return False
model_configs = [
{
"model_name": "qwen-tts-1.7B",
@@ -1220,6 +1393,13 @@ async def get_model_status():
"model_size": "0.6B",
"check_loaded": lambda: check_tts_loaded("0.6B"),
},
{
"model_name": "luxtts",
"display_name": "LuxTTS (Fast, CPU-friendly)",
"hf_repo_id": "YatharthS/LuxTTS",
"model_size": "default",
"check_loaded": check_luxtts_loaded,
},
{
"model_name": "whisper-base",
"display_name": "Whisper Base",
@@ -1402,6 +1582,7 @@ async def get_model_status():
async def trigger_model_download(request: models.ModelDownloadRequest):
"""Trigger download of a specific model."""
import asyncio
from .backends import get_tts_backend_for_engine
task_manager = get_task_manager()
progress_manager = get_progress_manager()
@@ -1415,6 +1596,10 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
"model_size": "0.6B",
"load_func": lambda: tts.get_tts_model().load_model("0.6B"),
},
"luxtts": {
"model_size": "default",
"load_func": lambda: get_tts_backend_for_engine("luxtts").load_model(),
},
"whisper-base": {
"model_size": "base",
"load_func": lambda: transcribe.get_whisper_model().load_model("base"),
@@ -1466,12 +1651,48 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
)
# Start download in background task (don't await)
asyncio.create_task(download_in_background())
_create_background_task(download_in_background())
# Return immediately - frontend should poll progress endpoint
return {"message": f"Model {request.model_name} download started"}
@app.post("/models/download/cancel")
async def cancel_model_download(request: models.ModelDownloadRequest):
"""Cancel or dismiss an errored/stale download task."""
task_manager = get_task_manager()
progress_manager = get_progress_manager()
removed = task_manager.cancel_download(request.model_name)
# Also clear progress state so the model doesn't show as downloading
progress_removed = False
with progress_manager._lock:
if request.model_name in progress_manager._progress:
del progress_manager._progress[request.model_name]
progress_removed = True
if removed or progress_removed:
return {"message": f"Download task for {request.model_name} cancelled"}
return {"message": f"No active task found for {request.model_name}"}
@app.post("/tasks/clear")
async def clear_all_tasks():
"""Clear all download tasks and progress state. Does not delete downloaded files."""
task_manager = get_task_manager()
progress_manager = get_progress_manager()
task_manager.clear_all()
with progress_manager._lock:
progress_manager._progress.clear()
progress_manager._last_notify_time.clear()
progress_manager._last_notify_progress.clear()
return {"message": "All task state cleared"}
@app.delete("/models/{model_name}")
async def delete_model(model_name: str):
"""Delete a downloaded model from the HuggingFace cache."""
@@ -1491,6 +1712,11 @@ async def delete_model(model_name: str):
"model_size": "0.6B",
"model_type": "tts",
},
"luxtts": {
"hf_repo_id": "YatharthS/LuxTTS",
"model_size": "default",
"model_type": "luxtts",
},
"whisper-base": {
"hf_repo_id": "openai/whisper-base",
"model_size": "base",
@@ -1507,12 +1733,12 @@ async def delete_model(model_name: str):
"model_type": "whisper",
},
"whisper-large": {
"hf_repo_id": "openai/whisper-large",
"hf_repo_id": "openai/whisper-large-v3",
"model_size": "large",
"model_type": "whisper",
},
}
if model_name not in model_configs:
raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}")
@@ -1525,6 +1751,11 @@ async def delete_model(model_name: str):
tts_model = tts.get_tts_model()
if tts_model.is_loaded() and tts_model.model_size == config["model_size"]:
tts.unload_tts_model()
elif config["model_type"] == "luxtts":
from .backends import get_tts_backend_for_engine
luxtts = get_tts_backend_for_engine("luxtts")
if luxtts.is_loaded():
luxtts.unload_model()
elif config["model_type"] == "whisper":
whisper_model = transcribe.get_whisper_model()
if whisper_model.is_loaded() and whisper_model.model_size == config["model_size"]:
@@ -1596,10 +1827,18 @@ async def get_active_tasks():
progress = progress_map.get(model_name)
if task:
# Prefer task error, fall back to progress manager error
error = task.error
if not error:
with progress_manager._lock:
pm_data = progress_manager._progress.get(model_name)
if pm_data:
error = pm_data.get("error")
active_downloads.append(models.ActiveDownloadTask(
model_name=model_name,
status=task.status,
started_at=task.started_at,
error=error,
))
elif progress:
# Progress exists but no task - create from progress data
@@ -1616,6 +1855,7 @@ async def get_active_tasks():
model_name=model_name,
status=progress.get("status", "downloading"),
started_at=started_at,
error=progress.get("error"),
))
# Get active generations
@@ -1634,6 +1874,75 @@ async def get_active_tasks():
)
# ============================================
# CUDA BACKEND MANAGEMENT
# ============================================
@app.get("/backend/cuda-status")
async def get_cuda_status():
"""Get CUDA backend download/availability status."""
from . import cuda_download
return cuda_download.get_cuda_status()
@app.post("/backend/download-cuda")
async def download_cuda_backend():
"""Download the CUDA backend binary. Returns immediately; track progress via SSE."""
from . import cuda_download
# Check if already downloaded
if cuda_download.get_cuda_binary_path() is not None:
raise HTTPException(status_code=409, detail="CUDA backend already downloaded")
async def _download():
try:
await cuda_download.download_cuda_binary()
except Exception as e:
import logging
logging.getLogger(__name__).error(f"CUDA download failed: {e}")
_create_background_task(_download())
return {"message": "CUDA backend download started", "progress_key": "cuda-backend"}
@app.delete("/backend/cuda")
async def delete_cuda_backend():
"""Delete the downloaded CUDA backend binary."""
from . import cuda_download
if cuda_download.is_cuda_active():
raise HTTPException(
status_code=409,
detail="Cannot delete CUDA backend while it is active. Switch to CPU first.",
)
deleted = await cuda_download.delete_cuda_binary()
if not deleted:
raise HTTPException(status_code=404, detail="No CUDA backend found to delete")
return {"message": "CUDA backend deleted"}
@app.get("/backend/cuda-progress")
async def get_cuda_download_progress():
"""Get CUDA backend download progress via Server-Sent Events."""
progress_manager = get_progress_manager()
async def event_generator():
async for event in progress_manager.subscribe("cuda-backend"):
yield event
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# ============================================
# STARTUP & SHUTDOWN
# ============================================
+3
View File
@@ -57,6 +57,7 @@ class GenerationRequest(BaseModel):
seed: Optional[int] = Field(None, ge=0)
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$")
instruct: Optional[str] = Field(None, max_length=500)
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts)$")
class GenerationResponse(BaseModel):
@@ -127,6 +128,7 @@ class HealthResponse(BaseModel):
gpu_type: Optional[str] = None # GPU type (CUDA, MPS, or None)
vram_used_mb: Optional[float] = None
backend_type: Optional[str] = None # Backend type (mlx or pytorch)
backend_variant: Optional[str] = None # Binary variant (cpu or cuda)
class ModelStatus(BaseModel):
@@ -154,6 +156,7 @@ class ActiveDownloadTask(BaseModel):
model_name: str
status: str
started_at: datetime
error: Optional[str] = None
class ActiveGenerationTask(BaseModel):
+7 -5
View File
@@ -19,15 +19,17 @@ def is_apple_silicon() -> bool:
def get_backend_type() -> Literal["mlx", "pytorch"]:
"""
Detect the best backend for the current platform.
Returns:
"mlx" on Apple Silicon (if MLX is available), "pytorch" otherwise
"mlx" on Apple Silicon (if MLX is available and functional), "pytorch" otherwise
"""
if is_apple_silicon():
try:
import mlx
import mlx.core # noqa: F401 — triggers native lib loading
return "mlx"
except ImportError:
# MLX not installed, fallback to PyTorch
except (ImportError, OSError, RuntimeError):
# MLX not installed, or native libraries failed to load inside a
# PyInstaller bundle (OSError on missing .dylib / .metallib).
# Fall through to PyTorch.
return "pytorch"
return "pytorch"
+5 -1
View File
@@ -327,6 +327,7 @@ async def create_voice_prompt_for_profile(
profile_id: str,
db: Session,
use_cache: bool = True,
engine: str = "qwen",
) -> dict:
"""
Create a combined voice prompt from all samples in a profile.
@@ -335,17 +336,20 @@ async def create_voice_prompt_for_profile(
profile_id: Profile ID
db: Database session
use_cache: Whether to use cached prompts
engine: TTS engine to create prompt for ("qwen" or "luxtts")
Returns:
Voice prompt dictionary
"""
from .backends import get_tts_backend_for_engine
# Get all samples for profile
samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all()
if not samples:
raise ValueError(f"No samples found for profile {profile_id}")
tts_model = get_tts_model()
tts_model = get_tts_backend_for_engine(engine)
if len(samples) == 1:
# Single sample - use directly
+12 -1
View File
@@ -9,15 +9,26 @@ alembic>=1.13.0
# ML models
torch>=2.1.0
transformers>=4.36.0
transformers>=4.36.0,<=4.57.6
accelerate>=0.26.0
huggingface_hub>=0.20.0
qwen-tts>=0.0.5
# LuxTTS (voice cloning engine)
# piper-phonemize needs custom index (no PyPI wheels)
--find-links https://k2-fsa.github.io/icefall/piper_phonemize.html
# linacodec is a git-only dep of Zipvoice (uv-only source, pip can't resolve it)
linacodec @ git+https://github.com/ysharma3501/LinaCodec.git
Zipvoice @ git+https://github.com/ysharma3501/LuxTTS.git
# Audio processing
librosa>=0.10.0
soundfile>=0.12.0
numpy>=1.24.0
numba>=0.60.0,<0.61.0
# HTTP client (for CUDA backend download)
httpx>=0.27.0
# Utilities
python-multipart>=0.0.6
+22
View File
@@ -64,7 +64,29 @@ if __name__ == "__main__":
default=None,
help="Data directory for database, profiles, and generated audio",
)
parser.add_argument(
"--version",
action="store_true",
help="Print version and exit",
)
args = parser.parse_args()
if args.version:
from backend import __version__
print(f"voicebox-server {__version__}")
sys.exit(0)
# Detect backend variant from binary name
# voicebox-server-cuda → sets VOICEBOX_BACKEND_VARIANT=cuda
import os
binary_name = os.path.basename(sys.executable).lower()
if "cuda" in binary_name:
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cuda"
logger.info("Backend variant: CUDA")
else:
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cpu"
logger.info("Backend variant: CPU")
logger.info(f"Parsed arguments: host={args.host}, port={args.port}, data_dir={args.data_dir}")
# Set data directory if provided
-8
View File
@@ -32,11 +32,3 @@ def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
sf.write(buffer, audio, sample_rate, format="WAV")
buffer.seek(0)
return buffer.read()
def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
"""Convert audio array to WAV bytes."""
buffer = io.BytesIO()
sf.write(buffer, audio, sample_rate, format="WAV")
buffer.seek(0)
return buffer.read()
+9
View File
@@ -72,6 +72,15 @@ class TaskManager:
"""Get all active generations."""
return list(self._active_generations.values())
def cancel_download(self, model_name: str) -> bool:
"""Cancel/dismiss a download task (removes it from active list)."""
return self._active_downloads.pop(model_name, None) is not None
def clear_all(self) -> None:
"""Clear all download and generation tasks."""
self._active_downloads.clear()
self._active_generations.clear()
def is_download_active(self, model_name: str) -> bool:
"""Check if a download is active."""
return model_name in self._active_downloads
+8 -3
View File
@@ -6,8 +6,13 @@ from PyInstaller.utils.hooks import copy_metadata
datas = []
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
datas += collect_data_files('qwen_tts')
datas += collect_data_files('mlx')
datas += collect_data_files('mlx_audio')
# Use collect_all (not collect_data_files) so native .dylib and .metallib
# files are bundled as binaries, not data. Without this, MLX raises OSError
# when loading Metal shaders inside the PyInstaller bundle.
from PyInstaller.utils.hooks import collect_all as _collect_all
_mlx_datas, _mlx_bins, _mlx_hidden = _collect_all('mlx')
_mlxa_datas, _mlxa_bins, _mlxa_hidden = _collect_all('mlx_audio')
datas += _mlx_datas + _mlxa_datas
datas += copy_metadata('qwen-tts')
hiddenimports += collect_submodules('qwen_tts')
hiddenimports += collect_submodules('jaraco')
@@ -18,7 +23,7 @@ hiddenimports += collect_submodules('mlx_audio')
a = Analysis(
['server.py'],
pathex=[],
binaries=[],
binaries=_mlx_bins + _mlxa_bins,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
+581
View File
@@ -0,0 +1,581 @@
# CUDA Backend Swap via Binary Replacement
> Status: Plan | Target: v0.2.0 | Created: 2026-03-12
## Problem
The CUDA PyTorch backend binary is ~2.4 GB. GitHub Releases has a 2 GB asset limit. The current release ships CPU-only PyTorch on Windows and Intel Mac — NVIDIA GPU users get no acceleration from official releases. This is the #1 reported issue category (19 open issues).
Users who want GPU today must clone the repo and run from source. That's not acceptable for a desktop app targeting non-technical users.
## Solution
Ship two backend binaries: a default CPU build (~150 MB) bundled with the app, and a downloadable CUDA build (~2.4 GB) hosted externally. When the user downloads the CUDA build, the app kills the current backend process, swaps in the CUDA binary, and relaunches — a backend-only restart. The frontend stays running, all UI state is preserved.
No subprocesses. No HTTP protocol between processes. No port allocation. No provider manager. The backend is still one monolithic process — just a different binary.
## Architecture
### What Exists Today
```
Tauri App
├── React Frontend (in-process webview)
└── voicebox-server (sidecar subprocess on :17493)
└── One PyInstaller binary: CPU PyTorch or MLX
```
**Sidecar lifecycle** (`tauri/src-tauri/src/main.rs`):
- `start_server` command spawns `voicebox-server` sidecar (line 181)
- Binary located at `tauri/src-tauri/binaries/voicebox-server-{platform-triple}`
- Tauri resolves the sidecar name via `externalBin` in `tauri.conf.json` (line 16)
- Waits up to 120s for "Uvicorn running" in stdout/stderr (line 286)
- `stop_server` kills the process tree (line 466)
**Frontend reconnection** (`app/src/lib/hooks/useServer.ts`):
- Health check polls `GET /health` every 30 seconds
- React Query cache retains data for 10 minutes after disconnect
- All UI state (Zustand stores, form data, open tabs) survives disconnection
- No active reconnect logic — just keeps polling until server responds
This means a backend restart is mostly invisible to the frontend: it sees a few seconds of failed health checks, then the server comes back. The only risk is in-flight operations (generation, transcription) failing mid-request.
### What Changes
```
Tauri App
├── React Frontend (in-process webview)
└── voicebox-server (sidecar subprocess on :17493)
└── One of:
├── voicebox-server-cpu (bundled, ~150 MB)
└── voicebox-server-cuda (downloaded, ~2.4 GB)
```
The CUDA binary is functionally identical to the CPU binary. Same FastAPI app, same endpoints, same code. The only difference is PyTorch is compiled with CUDA 12.1 support and the binary includes CUDA runtime libraries.
The user downloads it once. On every subsequent app launch, Tauri checks which binary variant exists and spawns the appropriate one.
## Implementation Plan
### Phase 1: Build Infrastructure
Build the CUDA binary in CI separately from the main release.
#### 1a. CUDA PyInstaller Build
Add a `build_binary_cuda.py` or parameterize the existing `build_binary.py`:
```python
# backend/build_binary.py — add flag
def build_server(cuda=False):
args = [
'server.py',
'--onefile',
'--name', f'voicebox-server-{"cuda" if cuda else "cpu"}',
]
if cuda:
args.extend([
'--hidden-import', 'torch.cuda',
'--hidden-import', 'torch.backends.cudnn',
])
# ... rest of existing build
```
The `--onefile` flag is already used, which produces a single executable. This is important — `--onedir` would complicate the swap (replacing a directory vs a file).
#### 1b. CI Workflow for CUDA Binary
New workflow: `.github/workflows/build-cuda.yml`
```yaml
name: Build CUDA Provider
on:
workflow_dispatch:
push:
tags: ["v*"]
jobs:
build-cuda:
runs-on: windows-latest # CUDA is Windows/Linux only
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- name: Install dependencies
run: |
pip install pyinstaller
pip install -r backend/requirements.txt
pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall
- name: Build CUDA binary
run: python backend/build_binary.py --cuda
- name: Split binary for GitHub Releases
run: |
python scripts/split_binary.py backend/dist/voicebox-server-cuda.exe \
--chunk-size 1900MB \
--output release-assets/
- name: Upload to R2
# Full binary to R2 (no size limit)
run: |
aws s3 cp backend/dist/voicebox-server-cuda.exe \
s3://voicebox-downloads/cuda/v${{ github.ref_name }}/voicebox-server-cuda.exe \
--endpoint-url ${{ secrets.R2_ENDPOINT }}
- name: Upload split parts to GitHub Release
# Split parts as GitHub Release assets (each <2 GB)
uses: softprops/action-gh-release@v1
with:
files: release-assets/*
```
Two distribution paths for redundancy:
- **Cloudflare R2**: Full binary, direct download, no size limit.
- **GitHub Releases**: Split into <2 GB chunks as fallback.
#### 1c. Binary Splitting Script
```python
# scripts/split_binary.py
"""Split a large binary into chunks for GitHub Releases."""
import hashlib
import argparse
from pathlib import Path
def split(input_path: Path, chunk_size: int, output_dir: Path):
output_dir.mkdir(parents=True, exist_ok=True)
data = input_path.read_bytes()
# Write SHA-256 of the complete file
sha256 = hashlib.sha256(data).hexdigest()
(output_dir / f"{input_path.stem}.sha256").write_text(
f"{sha256} {input_path.name}\n"
)
# Split into chunks
parts = []
for i in range(0, len(data), chunk_size):
part_name = f"{input_path.stem}.part{len(parts):02d}{input_path.suffix}"
part_path = output_dir / part_name
part_path.write_bytes(data[i:i + chunk_size])
parts.append(part_name)
# Write manifest
(output_dir / f"{input_path.stem}.manifest").write_text(
"\n".join(parts) + "\n"
)
print(f"Split into {len(parts)} parts, SHA-256: {sha256}")
```
### Phase 2: Download & Assemble in App
#### 2a. Backend Download Endpoint
Add to `backend/main.py`:
```python
@app.post("/backend/download-cuda")
async def download_cuda_backend():
"""Download the CUDA backend binary."""
# Returns immediately, runs download in background
task = asyncio.create_task(_download_cuda_binary())
task.add_done_callback(lambda t: logger.error(f"CUDA download failed: {t.exception()}") if t.exception() else None)
return {"status": "downloading"}
@app.get("/backend/cuda-status")
async def cuda_status():
"""Check if CUDA binary is available."""
cuda_path = _get_cuda_binary_path()
return {
"available": cuda_path is not None and cuda_path.exists(),
"active": _is_cuda_active(),
"download_progress": progress_manager.get_progress("cuda-backend"),
}
```
#### 2b. Download + Assemble + Verify Logic
New file: `backend/cuda_download.py`
Core logic:
```python
import hashlib
from pathlib import Path
from backend.config import get_data_dir
from backend.utils.progress import get_progress_manager
CUDA_DOWNLOAD_URL = "https://downloads.voicebox.sh/cuda/{version}/voicebox-server-cuda{ext}"
CUDA_CHECKSUMS = {
# Populated per release
"0.2.0-windows": "sha256:abc123...",
"0.2.0-linux": "sha256:def456...",
}
def get_cuda_binary_dir() -> Path:
"""Where CUDA binaries live. Inside the app's data directory."""
return get_data_dir() / "backends"
def get_cuda_binary_path() -> Path | None:
"""Return path to CUDA binary if it exists and is verified."""
d = get_cuda_binary_dir()
for name in ["voicebox-server-cuda.exe", "voicebox-server-cuda"]:
p = d / name
if p.exists():
return p
return None
async def download_cuda_binary(version: str):
"""Download, assemble (if split), and verify the CUDA binary."""
progress = get_progress_manager()
dest_dir = get_cuda_binary_dir()
dest_dir.mkdir(parents=True, exist_ok=True)
ext = ".exe" if sys.platform == "win32" else ""
url = CUDA_DOWNLOAD_URL.format(version=version, ext=ext)
# Download with progress tracking
temp_path = dest_dir / f"voicebox-server-cuda{ext}.download"
async with httpx.AsyncClient(follow_redirects=True) as client:
async with client.stream("GET", url) as response:
total = int(response.headers.get("content-length", 0))
downloaded = 0
with open(temp_path, "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
f.write(chunk)
downloaded += len(chunk)
progress.update("cuda-backend", downloaded, total)
# Verify checksum
sha256 = hashlib.sha256(temp_path.read_bytes()).hexdigest()
expected = CUDA_CHECKSUMS.get(f"{version}-{sys.platform}")
if expected and not expected.endswith(sha256):
temp_path.unlink()
raise ValueError(f"Checksum mismatch: expected {expected}, got sha256:{sha256}")
# Atomic move into place
final_path = dest_dir / f"voicebox-server-cuda{ext}"
temp_path.rename(final_path)
# Make executable on Unix
if sys.platform != "win32":
final_path.chmod(0o755)
progress.complete("cuda-backend")
```
Key points:
- Downloads to a `.download` temp file, verifies checksum, then atomically renames. No partial binaries left on crash.
- Progress tracked via the existing `ProgressManager` so the frontend SSE system works unchanged.
- CUDA binary lives in the **app data directory** (`data/backends/`), not alongside the app bundle. This avoids code-signing issues on macOS (though CUDA isn't relevant on macOS) and survives app updates.
#### 2c. Reassembly from Split Parts (GitHub Releases Fallback)
If the R2 download fails, fall back to downloading split parts from GitHub Releases:
```python
async def download_cuda_from_github(version: str):
"""Fallback: download split parts from GitHub Releases, reassemble."""
base_url = f"https://github.com/jamiepine/voicebox/releases/download/v{version}"
# Get manifest
manifest_url = f"{base_url}/voicebox-server-cuda.manifest"
async with httpx.AsyncClient(follow_redirects=True) as client:
manifest = (await client.get(manifest_url)).text
parts = [p.strip() for p in manifest.strip().splitlines()]
# Download checksum
sha256_url = f"{base_url}/voicebox-server-cuda.sha256"
expected_sha = (await client.get(sha256_url)).text.split()[0]
# Download parts
dest_dir = get_cuda_binary_dir()
dest_dir.mkdir(parents=True, exist_ok=True)
temp_path = dest_dir / "voicebox-server-cuda.exe.download"
total_downloaded = 0
with open(temp_path, "wb") as f:
for i, part_name in enumerate(parts):
part_url = f"{base_url}/{part_name}"
async with client.stream("GET", part_url) as response:
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
f.write(chunk)
total_downloaded += len(chunk)
get_progress_manager().update(
"cuda-backend", total_downloaded, None,
message=f"Downloading part {i+1}/{len(parts)}"
)
# Verify reassembled file
sha256 = hashlib.sha256(temp_path.read_bytes()).hexdigest()
if sha256 != expected_sha:
temp_path.unlink()
raise ValueError(f"Checksum mismatch after reassembly")
final_path = dest_dir / "voicebox-server-cuda.exe"
temp_path.rename(final_path)
get_progress_manager().complete("cuda-backend")
```
### Phase 3: Backend Restart (The Swap)
This is the core of the feature: kill the CPU backend, launch the CUDA backend, frontend reconnects automatically.
#### 3a. New Tauri Command: `restart_server`
Add to `tauri/src-tauri/src/main.rs`:
```rust
#[command]
async fn restart_server(
app: tauri::AppHandle,
state: State<'_, ServerState>,
use_cuda: Option<bool>,
) -> Result<String, String> {
println!("restart_server: use_cuda={:?}", use_cuda);
// 1. Stop the current server
stop_server(state.clone()).await?;
// 2. Brief wait for port release
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// 3. Start with the appropriate binary
// The start_server logic needs to check for CUDA binary
start_server(app, state, None).await
}
```
#### 3b. Modify `start_server` to Prefer CUDA Binary
The existing `start_server` uses `app.shell().sidecar("voicebox-server")` which resolves via Tauri's `externalBin` config. For the CUDA binary (which lives in the data directory, not the app bundle), we need an alternative launch path.
Modify `start_server` in `main.rs`:
```rust
// After the existing sidecar logic, before spawning:
// Check for CUDA binary in data directory
let cuda_binary = data_dir.join("backends")
.join(if cfg!(windows) { "voicebox-server-cuda.exe" } else { "voicebox-server-cuda" });
let (mut rx, child) = if cuda_binary.exists() {
println!("Found CUDA backend binary at {:?}", cuda_binary);
// Launch CUDA binary directly (not as Tauri sidecar)
let mut cmd = app.shell().command(cuda_binary.to_str().unwrap());
cmd = cmd.args([
"--data-dir",
data_dir.to_str().ok_or("Invalid data dir path")?,
"--port",
&SERVER_PORT.to_string(),
]);
if remote.unwrap_or(false) {
cmd = cmd.args(["--host", "0.0.0.0"]);
}
cmd.spawn().map_err(|e| format!("Failed to spawn CUDA backend: {}", e))?
} else {
// Existing sidecar launch (CPU binary bundled with app)
sidecar.spawn().map_err(|e| format!("Failed to spawn: {}", e))?
};
```
Key decisions:
- CUDA binary is launched via `app.shell().command()` (arbitrary path), not `app.shell().sidecar()` (bundled path). Tauri's sidecar system only resolves binaries within the app bundle.
- The CUDA binary gets the same args (`--data-dir`, `--port`) as the CPU binary. It's the same `server.py` entry point.
- Preference: if CUDA binary exists, use it. Otherwise fall back to bundled CPU. No user configuration needed.
#### 3c. Frontend: Trigger Restart After Download
Add to the platform lifecycle interface (`app/src/platform/types.ts`):
```typescript
interface PlatformLifecycle {
startServer(remote?: boolean): Promise<string>;
stopServer(): Promise<void>;
restartServer(useCuda?: boolean): Promise<string>; // new
// ...
}
```
Implement in `tauri/src/platform/lifecycle.ts`:
```typescript
async restartServer(useCuda?: boolean): Promise<string> {
const result = await invoke<string>('restart_server', { useCuda });
this.onServerReady?.();
return result;
}
```
#### 3d. Frontend: GPU Settings UI
Add a section to the Server Settings page (or Model Management). Minimal UI:
```
┌─────────────────────────────────────────────┐
│ GPU Acceleration │
│ │
│ Status: CPU only (no CUDA backend) │
│ │
│ [Download CUDA Backend (2.4 GB)] │
│ │
│ Requires an NVIDIA GPU with 4+ GB VRAM. │
│ The app will restart its backend process │
│ after download. Your work is preserved. │
└─────────────────────────────────────────────┘
```
After download:
```
┌─────────────────────────────────────────────┐
│ GPU Acceleration │
│ │
│ Status: ✓ CUDA backend active (RTX 4090) │
│ │
│ [Switch to CPU] [Delete CUDA Backend] │
└─────────────────────────────────────────────┘
```
#### 3e. Frontend: Reconnection During Restart
The current health poll interval is 30 seconds — too slow for a restart UX. During a restart, temporarily increase polling:
```typescript
// In the component that triggers restart:
const restart = async () => {
setRestarting(true);
try {
await platform.lifecycle.restartServer(true);
} catch (e) {
// Frontend will show "reconnecting" state
}
// Aggressively poll until health check succeeds
const interval = setInterval(async () => {
try {
await apiClient.getHealth();
clearInterval(interval);
setRestarting(false);
queryClient.invalidateQueries(); // Refresh all data
} catch {}
}, 1000); // Poll every 1s during restart
// Safety timeout
setTimeout(() => clearInterval(interval), 30000);
};
```
### Phase 4: Auto-Detection on Startup
No user action needed on subsequent launches. The preference logic in `start_server` (Phase 3b) handles this:
1. App launches → `start_server` called
2. Check `data/backends/voicebox-server-cuda{.exe}`
3. If exists → launch CUDA binary
4. If not → launch bundled CPU binary
The user downloads CUDA once, and every future app launch (including after updates) uses it automatically. The CUDA binary lives in the data directory, not the app bundle, so app updates don't overwrite it.
### Phase 5: Handling Version Mismatches
When the app updates but the CUDA binary is from an older version, the API might be incompatible. Handle this by:
1. Add `--version` flag to `server.py`:
```python
parser.add_argument("--version", action="store_true")
# If invoked with --version, print version and exit
if args.version:
from backend import __version__
print(f"voicebox-server {__version__}")
sys.exit(0)
```
2. In `start_server` (Rust), before launching the CUDA binary:
```rust
// Quick version check
let version_output = std::process::Command::new(cuda_binary.to_str().unwrap())
.arg("--version")
.output();
match version_output {
Ok(output) => {
let version = String::from_utf8_lossy(&output.stdout);
let app_version = env!("CARGO_PKG_VERSION");
if !version.contains(app_version) {
println!("CUDA binary version mismatch (app: {}, cuda: {}), falling back to CPU",
app_version, version.trim());
// Fall through to CPU sidecar launch
}
}
Err(_) => {
println!("Failed to check CUDA binary version, falling back to CPU");
}
}
```
3. Frontend shows a notification: "Your GPU backend needs an update. [Download latest] or [Use CPU for now]"
## Files Changed
### New Files
| File | Purpose |
|------|---------|
| `backend/cuda_download.py` | Download, reassemble, verify CUDA binary |
| `scripts/split_binary.py` | Split binary into <2 GB chunks for GitHub Releases |
| `.github/workflows/build-cuda.yml` | CI: build + upload CUDA binary |
### Modified Files
| File | Change |
|------|--------|
| `tauri/src-tauri/src/main.rs` | Add `restart_server` command, modify `start_server` to check for CUDA binary in data dir |
| `backend/server.py` | Add `--version` flag |
| `backend/main.py` | Add `/backend/download-cuda`, `/backend/cuda-status`, `/backend/progress/cuda-backend` endpoints |
| `backend/build_binary.py` | Accept `--cuda` flag to build CUDA variant |
| `app/src/platform/types.ts` | Add `restartServer` to lifecycle interface |
| `tauri/src/platform/lifecycle.ts` | Implement `restartServer` |
| `app/src/components/ServerSettings/` | New GPU acceleration section |
| `.github/workflows/release.yml` | Trigger CUDA build workflow on tag |
### NOT Changed
| File | Why |
|------|-----|
| `backend/backends/__init__.py` | No changes to the TTSBackend singleton or factory. CUDA binary runs the same code. |
| `backend/backends/pytorch_backend.py` | Already detects CUDA at runtime (line 28-49). No changes needed. |
| `app/src/lib/api/client.ts` | API is identical between CPU and CUDA backends. |
| `app/src/lib/hooks/useGenerationForm.ts` | Generation flow is unchanged. |
## What This Doesn't Solve
- **Multi-model support** — This is purely about GPU acceleration. LuxTTS, Chatterbox, etc. need the in-process model registry, which is an independent workstream.
- **AMD GPU support** — DirectML/ROCm needs a different PyTorch build. Same pattern applies (another binary variant) but deferred.
- **Linux CUDA** — Same approach works, just another CI matrix entry. Can be added in the same release or shortly after.
- **Remote server mode** — Users who want to run TTS on a different machine still need the external provider architecture. Separate concern.
## What This DOES Solve
- **19 "GPU not detected" issues** — Users download the CUDA backend, restart, GPU works.
- **2 GB GitHub Release limit** — Binary splitting + R2 hosting.
- **Update burden** — App updates don't re-download the 2.4 GB CUDA binary. It persists in the data directory.
- **First-run experience** — App works immediately on CPU. GPU is an optional enhancement, not a setup blocker.
## Rollout Plan
1. Build and test CUDA binary locally on Windows with an NVIDIA GPU.
2. Set up R2 bucket at `downloads.voicebox.sh/cuda/`.
3. Ship the backend restart + download UI in v0.2.0.
4. Announce: "GPU acceleration is here — one click in Settings."
## Risks
| Risk | Mitigation |
|------|-----------|
| CUDA binary doesn't work on some GPU/driver combos | `/health` endpoint reports GPU info. Fallback to CPU if CUDA init fails. Clear error message. |
| Antivirus flags downloaded binary (Windows) | Code-sign the CUDA binary in CI. Document AV exceptions. |
| Data dir CUDA binary survives app uninstall | Document in uninstall notes. Not a real problem — it's just a file. |
| Version mismatch after app update | Version check on startup (Phase 5). Auto-fallback to CPU. Prompt to re-download. |
| R2 downtime | GitHub Releases split-binary fallback. |
| Download interrupted | Temp file with `.download` extension. Atomic rename on completion. Resume not implemented in v1 — restart download from scratch. |
+133
View File
@@ -0,0 +1,133 @@
# CUDA Backend Swap — Implementation Summary
> Status: **Complete** | Branch: `feat/cuda-backend-swap` | Created: 2026-03-12
## What This Is
A standalone feature that lets users download a CUDA-enabled backend binary (~2.4 GB) and swap it in via a backend-only restart. The frontend stays running, all UI state is preserved. This solves the #1 user pain point: 19 open issues about "GPU not detected" caused by GitHub's 2 GB release asset limit preventing CUDA binaries from shipping in official releases.
## How It Works
```
User clicks "Download CUDA Backend" in Settings
→ Backend fetches manifest from GitHub Releases
→ Downloads split parts (<2 GB each), concatenates them
→ SHA-256 integrity check on reassembled binary
→ Binary placed in {app_data_dir}/backends/voicebox-server-cuda
→ User clicks "Switch to CUDA Backend"
→ Tauri kills CPU process, launches CUDA binary, frontend reconnects
→ On all future app launches, CUDA binary is auto-detected and used
```
The CUDA binary is functionally identical to the CPU binary — same FastAPI app, same endpoints, same code. The only difference is PyTorch compiled with CUDA 12.1 and bundled CUDA runtime libraries.
## Architecture Decisions
**Backend-only restart, not full app restart.** The Tauri shell kills the current `voicebox-server` process, waits 1 second for port release, and spawns the new binary. The React frontend stays running. Health polling detects the new backend within seconds.
**No provider/subprocess architecture.** This is explicitly not the PR #33 approach (10K+ lines, 136 files, 22 bugs). One process at a time. The CUDA binary replaces the CPU binary — it doesn't run alongside it.
**Data directory, not app bundle.** The CUDA binary lives in `{app_data_dir}/backends/`, which persists across app updates and avoids code-signing issues. The bundled CPU binary in the app bundle is untouched.
**Version mismatch protection.** On startup, Rust runs `voicebox-server-cuda --version` and compares to the app version from `tauri.conf.json`. If they don't match (e.g., after an app update), it falls back to the bundled CPU binary silently.
**GitHub Releases distribution.** The CUDA binary is split into <2 GB chunks (GitHub's asset limit) via `scripts/split_binary.py`. The app downloads a manifest, fetches each part, concatenates them, and runs a SHA-256 integrity check to verify reassembly. No external hosting needed.
## Files Changed
### New Files
| File | Lines | Purpose |
|------|-------|---------|
| `backend/cuda_download.py` | ~190 | Download split parts from GitHub Releases, reassemble, verify integrity |
| `scripts/split_binary.py` | ~80 | Split large binary into <2 GB chunks with SHA-256 manifest |
| `.github/workflows/build-cuda.yml` | ~70 | CI workflow: build CUDA binary, split, upload to GitHub Releases |
| `app/src/components/ServerSettings/GpuAcceleration.tsx` | 371 | GPU Acceleration UI card (status, download, restart, delete) |
| `docs/plans/CUDA_BACKEND_SWAP.md` | 581 | Original implementation plan (5 phases with code sketches) |
| `docs/plans/CUDA_BACKEND_SWAP_FINAL.md` | this file | Final implementation summary |
| `docs/plans/PROJECT_STATUS.md` | 462 | Full project triage (all PRs, issues, architecture) |
| `docs/plans/PR33_CUDA_PROVIDER_REVIEW.md` | ~350 | Detailed code review of PR #33 (22 bugs documented) |
### Modified Files
| File | What Changed |
|------|-------------|
| `backend/build_binary.py` | Added `--cuda` flag, parameterized output binary name |
| `backend/server.py` | Added `--version` flag, auto-detect backend variant from binary name (`VOICEBOX_BACKEND_VARIANT` env var) |
| `backend/main.py` | 4 new endpoints (`/backend/cuda-status`, `/backend/download-cuda`, `/backend/cuda`, `/backend/cuda-progress`), health endpoint returns `backend_variant` |
| `backend/models.py` | `HealthResponse` model: added `backend_variant` field |
| `backend/requirements.txt` | Added `httpx>=0.27.0` for async HTTP downloads |
| `tauri/src-tauri/src/main.rs` | `restart_server` command (stop → wait → start), `start_server` checks for CUDA binary in data dir and launches via `shell().command()`, version mismatch check |
| `app/src/platform/types.ts` | `PlatformLifecycle.restartServer()` added |
| `tauri/src/platform/lifecycle.ts` | `restartServer()` implementation via `invoke('restart_server')` |
| `web/src/platform/lifecycle.ts` | `restartServer()` noop for web platform |
| `app/src/lib/api/types.ts` | `CudaStatus`, `CudaDownloadProgress` interfaces; `HealthResponse` updated with `gpu_type`, `backend_type`, `backend_variant` |
| `app/src/lib/api/client.ts` | `getCudaStatus()`, `downloadCudaBackend()`, `deleteCudaBackend()` methods |
| `app/src/components/ServerTab/ServerTab.tsx` | Wired in `<GpuAcceleration />` component (Tauri-only) |
## Backend API Endpoints
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/backend/cuda-status` | Returns `{ available, active, binary_path, downloading, download_progress }` |
| `POST` | `/backend/download-cuda` | Starts background download; returns immediately. Track via SSE. |
| `DELETE` | `/backend/cuda` | Deletes CUDA binary (blocked if CUDA is currently active) |
| `GET` | `/backend/cuda-progress` | SSE stream of download progress (reuses existing `ProgressManager`) |
The existing `GET /health` endpoint now returns two new fields:
- `backend_type`: `"pytorch"` or `"mlx"` (existing detection)
- `backend_variant`: `"cpu"` or `"cuda"` (set from `VOICEBOX_BACKEND_VARIANT` env var)
## Frontend UI States
The `GpuAcceleration` card in Server Settings handles these states:
1. **Native GPU detected** (MPS, MLX, XPU, DirectML) — Shows info message, no download needed
2. **No CUDA binary** — Download button with size estimate, description of requirements
3. **Downloading** — SSE-driven progress bar with bytes/total and percentage
4. **Downloaded, not active** — "Switch to CUDA Backend" button + "Remove" option
5. **CUDA active** — Shows CUDA badge, "Switch to CPU Backend" button
6. **Restarting** — Spinner with phase text, 1s health polling as safety net
7. **Error** — Red error message with details
### Key UX detail: switching to CPU
Since `start_server` always prefers the CUDA binary if it exists on disk, "Switch to CPU" must delete the CUDA binary first, then restart. The user can re-download later. This avoids a persistent configuration mechanism (no new state to manage, no new config file, no DB column).
## Rust: Server Lifecycle
```
start_server
├── Check for CUDA binary at {data_dir}/backends/voicebox-server-cuda
├── If found: run --version, compare to app version
│ ├── Match: launch via shell().command() with --data-dir, --port
│ └── Mismatch: log warning, fall through to CPU
└── Else: launch bundled sidecar via shell().sidecar()
restart_server
├── stop_server (kill process tree)
├── wait 1 second for port release
└── start_server (auto-detects CUDA)
```
## What This Doesn't Cover
- **AMD GPU / ROCm / DirectML binary** — Same pattern, different PyTorch build. Future PR.
- **Linux CUDA** — Same approach, just another CI matrix entry. Can ship same release.
- **Multi-model support** — LuxTTS, Chatterbox, etc. are a separate architectural concern (in-process model registry). Independent of binary variant.
- **Download resume** — If download is interrupted, it restarts from scratch. Acceptable for v1.
- **Remote server CUDA** — Users running voicebox-server on a remote machine manage their own binaries. This feature is for the desktop app.
## Testing Checklist
- [ ] Build CUDA binary locally with `python backend/build_binary.py --cuda`
- [ ] `voicebox-server-cuda --version` prints correct version
- [ ] Place CUDA binary in `{data_dir}/backends/`, launch app → auto-detects and uses it
- [ ] Version mismatch: rename binary to have wrong version → falls back to CPU
- [ ] Frontend: GpuAcceleration card shows correct state for CPU, CUDA available, CUDA active
- [ ] Download flow: POST triggers download, SSE progress works, completion updates status
- [ ] Switch to CUDA: restart works, health endpoint shows `backend_variant: "cuda"`
- [ ] Switch to CPU: deletes binary, restarts, health shows `backend_variant: "cpu"`
- [ ] Delete CUDA while active: returns 409 error
- [ ] Split binary script: `python scripts/split_binary.py` creates manifest + parts + sha256
- [ ] Native GPU (macOS MPS): shows info message, no download section
+500
View File
@@ -0,0 +1,500 @@
# PR #33 — CUDA Provider System Review
> Branch: `external-provider-binaries` | Created: 2026-02-01 | 34 commits, 136 files, +10,266 lines
> Reviewed: 2026-03-12
---
## The Problem
The CUDA PyTorch binary is ~2.4 GB. GitHub Releases has a 2 GB artifact limit. This means:
- Windows/Linux users with NVIDIA GPUs cannot get GPU acceleration from official releases
- 19 open issues about "GPU not detected" — the single most reported problem category
- Users who want GPU must clone the repo and run from source
- Every app update forces re-download of the entire binary
This is the #1 user pain point by volume.
---
## What PR #33 Does
Splits the monolithic Voicebox binary into two layers:
```
┌──────────────────────────────────────┐
│ Main App (~150MB Win/Lin, ~300 Mac) │
│ Tauri + React + FastAPI + Whisper │
│ No PyTorch. MLX bundled on macOS. │
├──────────────────────────────────────┤
│ HTTP (localhost) │
├──────────────────────────────────────┤
│ Provider Binary (downloaded later) │
│ PyTorch CPU (~300MB) │
│ PyTorch CUDA (~2.4GB) │
│ Hosted on Cloudflare R2 │
└──────────────────────────────────────┘
```
### New Backend Code
| File | Purpose |
|------|---------|
| `backend/providers/__init__.py` (327 lines) | `ProviderManager` — lifecycle management, subprocess spawning, port allocation |
| `backend/providers/base.py` (97 lines) | `TTSProvider` Protocol definition |
| `backend/providers/bundled.py` (144 lines) | `BundledProvider` — wraps existing MLX/PyTorch backends for the new interface |
| `backend/providers/local.py` (191 lines) | `LocalProvider` — HTTP client that talks to external provider processes |
| `backend/providers/installer.py` (262 lines) | Download, extract, delete provider binaries |
| `backend/providers/types.py` (34 lines) | `ProviderType` enum, `ProviderInfo` dataclass |
| `backend/providers/checksums.py` (11 lines) | Checksum dict (currently empty) |
### Provider Servers (Standalone Executables)
| File | Purpose |
|------|---------|
| `providers/pytorch-cpu/main.py` (238 lines) | FastAPI server wrapping PyTorch CPU inference |
| `providers/pytorch-cuda/main.py` (238 lines) | FastAPI server wrapping PyTorch CUDA inference |
| `providers/pytorch-*/build.py` | PyInstaller build scripts |
| `providers/pytorch-*/requirements.txt` | Isolated dependencies |
### Frontend
| File | Purpose |
|------|---------|
| `app/src/components/ServerSettings/ProviderSettings.tsx` (400 lines) | Provider download/start/stop/delete UI |
### Also Included (Scope Creep)
The PR bundles several unrelated changes that inflate the diff:
- `docs2/` — Entire documentation site rewrite (Fumadocs migration, ~3000 lines)
- `Dockerfile`, `Dockerfile.cuda`, `docker-compose.yml` — Docker support
- `landing/` — Banner removal
- UI refactors in Stories, History, Voice Profiles, Audio tab
- Linux audio capture module
- Various dependency bumps
---
## Bug Report
### Critical — Will Crash at Runtime
#### C1. Provider `generate` endpoint can't parse requests
**`providers/pytorch-cpu/main.py:91-97`** (same in pytorch-cuda)
```python
@app.post("/tts/generate")
async def generate(
text: str,
voice_prompt: dict,
language: str = "auto",
seed: int = None,
model_size: str = "1.7B"
):
```
Parameters declared as function arguments. FastAPI interprets these as **query parameters**, not JSON body. But `LocalProvider.generate()` sends a JSON body via `httpx`:
```python
# backend/providers/local.py:33-40
response = await self.client.post("/tts/generate", json={
"text": text,
"voice_prompt": voice_prompt,
...
})
```
**Result:** Every generation call to an external provider returns HTTP 422 (Validation Error). The generation path is completely broken for external providers.
**Fix:** Use a Pydantic request body model:
```python
class GenerateRequest(BaseModel):
text: str
voice_prompt: dict
language: str = "auto"
seed: Optional[int] = None
model_size: str = "1.7B"
@app.post("/tts/generate")
async def generate(data: GenerateRequest):
```
#### C2. Timeout error handler references undefined variables
**`backend/providers/__init__.py:82-90`**
```python
stdout_content = ""
stderr_content = ""
# ... threads write to stdout_queue / stderr_queue ...
except TimeoutError:
while not stdout_queue.empty():
stdout_lines.append(stdout_queue.get_nowait()) # NameError
while not stderr_queue.empty():
stderr_lines.append(stderr_queue.get_nowait()) # NameError
```
`stdout_lines` and `stderr_lines` are never defined. Every provider startup timeout will throw `NameError`, masking the real failure cause. Then `stdout_content` and `stderr_content` are logged but they're still empty strings — the queue data is never assigned back.
#### C3. Sync `get_tts_model()` ignores external provider in async context
**`backend/tts.py:15-29`**
```python
def get_tts_model():
manager = get_provider_manager()
loop = asyncio.get_event_loop()
if loop.is_running():
# We're in an async context, but can't await here
return manager._get_default_provider()
```
FastAPI routes are async. This function is called from several code paths during generation. In async context it **always returns the bundled provider**, ignoring whatever external provider the user selected. The user downloads and starts a CUDA provider, but generation still runs on CPU.
### Critical — Security
#### C4. Path traversal via `tarfile.extractall()` (CVE-2007-4559)
**`backend/providers/installer.py:115-118`**
```python
with tarfile.open(archive_path, 'r:gz') as tar_ref:
tar_ref.extractall(providers_dir)
```
No member path filtering. A crafted `.tar.gz` from a compromised CDN can write files anywhere on disk via `../` entries. Python 3.12+ emits a deprecation warning for exactly this pattern.
**Fix:**
```python
tar_ref.extractall(providers_dir, filter='data') # Python 3.12+
```
Or manually validate each member:
```python
for member in tar_ref.getmembers():
member_path = os.path.join(providers_dir, member.name)
if not os.path.commonpath([providers_dir, member_path]).startswith(str(providers_dir)):
raise ValueError(f"Path traversal attempt: {member.name}")
tar_ref.extractall(providers_dir)
```
#### C5. No checksum verification on downloaded binaries
**`backend/providers/checksums.py`**
```python
PROVIDER_CHECKSUMS = {}
```
Empty dict. `download_provider()` in `installer.py` never calls any verification function. Downloaded binaries are `chmod 0o755`'d and executed without integrity checks. A MitM or CDN compromise delivers arbitrary code.
**Fix:** Populate checksums per release. Verify SHA-256 after download before extraction:
```python
import hashlib
sha256 = hashlib.sha256(archive_path.read_bytes()).hexdigest()
if sha256 != expected:
archive_path.unlink()
raise ValueError(f"Checksum mismatch for {provider_type}")
```
#### C6. Provider servers have no authentication
**`providers/pytorch-cpu/main.py:18-23`**
```python
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
...
)
```
Zero auth. Any local process — including browser JavaScript via localhost — can send requests to the provider on its ephemeral port. Port is discoverable by scanning.
**Fix:** Generate a random token in the parent process, pass via environment variable to the child, validate in middleware:
```python
# Parent (ProviderManager)
token = secrets.token_urlsafe(32)
env = {**os.environ, "VOICEBOX_PROVIDER_TOKEN": token}
process = subprocess.Popen([...], env=env, ...)
# Child (provider server)
EXPECTED_TOKEN = os.environ.get("VOICEBOX_PROVIDER_TOKEN")
@app.middleware("http")
async def verify_token(request, call_next):
if request.headers.get("X-Provider-Token") != EXPECTED_TOKEN:
return JSONResponse(status_code=403, content={"error": "unauthorized"})
return await call_next(request)
```
### Major — Will Cause Problems in Production
#### M1. Leaked file handles on subprocess stdout/stderr
**`backend/providers/__init__.py:68-73`**
```python
process = subprocess.Popen(
[...],
stdout=open(stdout_log, 'w'), # leaked handle
stderr=open(stderr_log, 'w'), # leaked handle
)
```
File handles passed directly from `open()` without storing references. They close on GC, not deterministically. On Windows the log files stay locked and unreadable until the process exits.
**Fix:**
```python
stdout_fh = open(stdout_log, 'w')
stderr_fh = open(stderr_log, 'w')
try:
process = subprocess.Popen([...], stdout=stdout_fh, stderr=stderr_fh)
finally:
stdout_fh.close()
stderr_fh.close()
```
#### M2. No subprocess crash detection or recovery
**`backend/providers/__init__.py:56-110`**
Once `start_provider()` succeeds, the `Popen` object is stored but never polled. If the provider process crashes mid-session:
- `LocalProvider` HTTP calls fail with `httpx.ConnectError`
- No auto-restart
- No health-check loop
- User sees cryptic "connection refused" errors
- Must manually restart provider from UI
**Fix:** Background asyncio task that polls `process.poll()` every few seconds. On crash, update provider status and optionally auto-restart:
```python
async def _watch_provider_process(self):
while self._provider_process and self._provider_process.poll() is None:
await asyncio.sleep(5)
if self._provider_process and self._provider_process.returncode != 0:
logger.error(f"Provider crashed with code {self._provider_process.returncode}")
self.active_provider = self._default_provider
# Notify frontend via next health check
```
#### M3. Port allocation race condition (TOCTOU)
**`backend/providers/__init__.py:145-149`**
```python
def _get_free_port(self) -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('', 0))
return s.getsockname()[1]
# Socket closed here — port is free but unprotected
```
Between this function returning and the provider process binding, another process can claim the port. On busy systems this causes "address already in use" failures.
**Fix options:**
- Pass the socket fd to the child process (complex, platform-specific)
- Retry with a new port on bind failure (simplest)
- Use a fixed port range and try sequentially
#### M4. `delete_provider()` leaves hundreds of MB behind
**`backend/providers/installer.py:155-168`**
```python
provider_path.unlink() # Deletes just the executable
```
PyInstaller `--onedir` produces a directory with the executable plus all shared libraries. `unlink()` only removes the binary file, leaving behind hundreds of MB of `.so`/`.dll`/`.dylib` files.
**Fix:**
```python
provider_dir = provider_path.parent
shutil.rmtree(provider_dir)
```
#### M5. `LocalProvider.combine_voice_prompts()` bypasses the provider
**`backend/providers/local.py:68-88`**
This method imports from `..utils.audio` and processes locally instead of sending to the provider server. If the user chose an external provider because they lack local dependencies (e.g., no PyTorch on the machine), this will crash with `ImportError`.
#### M6. Download errors silently swallowed
**`backend/main.py:1640`**
```python
asyncio.create_task(download_provider(provider_type))
```
Fire-and-forget. If the download fails, the exception is logged as "Task exception was never retrieved." The frontend SSE progress stream may hang forever showing "downloading" without the error.
**Fix:** Store the task, add an error callback:
```python
task = asyncio.create_task(download_provider(provider_type))
task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
```
And propagate errors through the progress manager so the SSE stream surfaces them.
#### M7. `LocalProvider.is_loaded()` always returns `True`
**`backend/providers/local.py:105-108`**
```python
def is_loaded(self) -> bool:
return True # Return True optimistically
```
Health/status checks always report the model as loaded for external providers, even when the provider hasn't loaded anything yet. This breaks the "download model if not cached" logic in the generation flow.
#### M8. `instruct` parameter silently dropped
**`backend/providers/local.py:33-40`**
The `generate()` method accepts `instruct` but never includes it in the JSON payload. The provider server also hardcodes `instruct=None`. Delivery instructions silently do nothing for external providers.
### Minor
| # | Issue | Location |
|---|-------|----------|
| m1 | `pytorch-cpu/main.py` and `pytorch-cuda/main.py` are 95% identical | Both files |
| m2 | `build.py` scripts also nearly identical | Both build files |
| m3 | `navigator.platform` is deprecated | `ProviderSettings.tsx:20-23` |
| m4 | `console.log('currentProvider', ...)` left in | `ProviderSettings.tsx:151` |
| m5 | `ProviderType` enum defined but never used for validation | `types.py:10-15` |
| m6 | `list_installed()` reimplements platform detection | `__init__.py:129-143` |
| m7 | New `httpx.AsyncClient` created per health poll iteration | `__init__.py:151-165` |
| m8 | `load_model_async()` only stores size, doesn't actually preload | `local.py:95-99` |
---
## Scope Creep
The PR should be split. These are independent changes bundled in:
| Change | Lines | Should Be Separate PR |
|--------|-------|-----------------------|
| `docs2/` site rewrite | ~3000 | Yes |
| Docker support (Dockerfile, compose, docs) | ~600 | Yes — overlaps with PR #161 |
| Landing page banner removal | ~30 | Yes |
| UI refactors (Stories, History, Voices, Audio) | ~400 | Yes |
| Linux audio capture module | ~10 | Yes |
| Dependency bumps | ~100 | Yes |
**Core provider system** (the actual feature) is ~2500 lines across backend + frontend + provider servers. That's the reviewable scope.
---
## What's Well-Designed
These parts should survive any rewrite:
1. **`TTSProvider` Protocol** (`base.py`) — Structural typing via `@runtime_checkable Protocol`. Right pattern. Comprehensive interface.
2. **`BundledProvider` / `LocalProvider` split** — Clean separation between in-process and HTTP-based inference. The wrapper pattern in `BundledProvider` correctly delegates to existing `TTSBackend`.
3. **R2 distribution strategy** — Provider binaries on Cloudflare R2, main app on GitHub Releases. Correct solution to the 2 GB limit.
4. **Progress tracking** — SSE-based download progress integrated with the existing `ProgressManager`. Good UX.
5. **Subprocess log files** — Writing provider stdout/stderr to log files in the data directory is pragmatic and debuggable.
6. **Frontend `ProviderSettings.tsx`** — Clean component structure. Proper loading/disabled states, confirmation dialogs, platform-aware visibility.
7. **CI split** — Separate `build-providers` and `release` jobs. Providers built and uploaded to R2 independently.
---
## Options for Moving Forward
### Option A — Fix and Slim PR #33
Strip the PR down to just the provider system (~2500 lines). Fix the 5 critical and 8 major bugs. Rebase onto current `main`.
**Effort:** ~2-3 days focused work
**Pros:** Full auto-managed provider lifecycle. Foundation for multi-model.
**Cons:** Still complex. Process management is inherently fragile cross-platform.
### Option B — Manual External Server Mode
Skip subprocess management entirely. Ship a "Connect to External Server" feature:
1. User downloads CUDA provider zip from `downloads.voicebox.sh`
2. User runs it manually (`./tts-provider-pytorch-cuda --port 8100`)
3. In Voicebox UI: paste `http://localhost:8100` as the TTS server URL
4. Voicebox routes generation to that URL via `LocalProvider`
This reuses `LocalProvider` from PR #33 but removes:
- `ProviderManager` subprocess spawning (the buggiest part)
- `installer.py` download/extract logic (the security risks)
- Port allocation (user picks the port)
- Process lifecycle management (user's responsibility)
**Effort:** ~1 day. `LocalProvider` + a URL input field + health check.
**Pros:** Simple, reliable, no process management bugs, no security surface.
**Cons:** Manual setup. Not seamless. But CUDA users are already technical (they run from source today).
### Option C — Hybrid (Recommended)
Ship Option B first as v0.2.0. Then iterate toward auto-management:
**Phase 1 (v0.2.0):** Manual external server mode
- `LocalProvider` HTTP client (from PR #33, with the 422 bug fixed)
- Server URL input in Settings
- Health indicator
- CUDA provider published as standalone zip on R2
- One page of docs: "download, unzip, run, paste URL"
**Phase 2 (v0.2.x):** Auto-download + auto-start
- `installer.py` with checksum verification and safe extraction
- `ProviderManager` subprocess spawning with crash detection
- Provider settings UI with download/start/stop buttons
**Phase 3 (v0.3.0):** Multi-model providers
- Provider per model family (not just per hardware)
- LuxTTS provider, Chatterbox provider, etc.
- Provider marketplace / registry
This gets CUDA into users' hands immediately (Phase 1 is ~1 day) while building toward the full vision incrementally. Each phase is independently shippable and testable.
### Option D — GitHub Workaround
Avoid the provider architecture entirely. Host CUDA binaries on R2 and add a download link in the app that opens the user's browser. User downloads the full monolithic CUDA build, replaces their existing install.
**Effort:** Minimal — just hosting + a link.
**Pros:** Zero architecture changes.
**Cons:** Doesn't solve: multi-model, independent app updates, or the re-download-everything-on-update problem. Kicks the can.
---
## Recommendation
**Option C (Hybrid)** is the strongest path. Specifically:
1. **Now:** Close PR #33 as-is. It's too large, too buggy, and too stale to salvage as a single merge.
2. **Extract:** Cherry-pick the good parts into small focused PRs:
- PR: `TTSProvider` Protocol + `BundledProvider` + `LocalProvider` (the abstractions)
- PR: Provider settings UI (the frontend)
- PR: `installer.py` + checksums (the download system)
- PR: CI changes for R2 upload (the distribution)
3. **Ship Phase 1:** Manual external server mode. One small PR. Unblocks every CUDA user immediately.
4. **Iterate:** Layer in auto-management once the manual mode is proven stable.
The critical bugs in PR #33 (C1-C6) are all fixable, but the PR's size makes review unreliable. Splitting it ensures each piece gets proper attention and nothing ships broken.
---
## Bug Summary
| Severity | Count | Blocks Ship? |
|----------|-------|-------------|
| Critical (runtime crash) | 3 | Yes — C1, C2, C3 |
| Critical (security) | 3 | Yes — C4, C5, C6 |
| Major | 8 | Some — M1, M2, M3 are high risk |
| Minor | 8 | No |
| **Total** | **22** | |
+462
View File
@@ -0,0 +1,462 @@
# Voicebox Project Status & Roadmap
> Last updated: 2026-03-12 | Current version: **v0.1.13** | 13.1k stars | 176 open issues | 28 open PRs
---
## Table of Contents
1. [Architecture Overview](#architecture-overview)
2. [Current State](#current-state)
3. [Open PRs — Triage & Analysis](#open-prs--triage--analysis)
4. [Open Issues — Categorized](#open-issues--categorized)
5. [Existing Plan Documents — Status](#existing-plan-documents--status)
6. [New Model Integration — Landscape](#new-model-integration--landscape)
7. [Architectural Bottlenecks](#architectural-bottlenecks)
8. [Recommended Priorities](#recommended-priorities)
---
## Architecture Overview
```
┌─────────────────────────────────────────────────────┐
│ Tauri Shell (Rust) │
│ ┌───────────────────────────────────────────────┐ │
│ │ React Frontend (app/) │ │
│ │ Zustand stores · API client · Generation UI │ │
│ │ Stories Editor · Voice Profiles · Model Mgmt │ │
│ └──────────────────────┬────────────────────────┘ │
│ │ HTTP :17493 │
│ ┌──────────────────────▼────────────────────────┐ │
│ │ FastAPI Backend (backend/) │ │
│ │ ┌─────────────┐ ┌───────────┐ ┌─────────┐ │ │
│ │ │ TTSBackend │ │ STTBackend│ │ Profiles│ │ │
│ │ │ (Protocol) │ │ (Whisper) │ │ History │ │ │
│ │ │ ┌────────┐ │ └───────────┘ │ Stories │ │ │
│ │ │ │PyTorch │ │ └─────────┘ │ │
│ │ │ │or MLX │ │ │ │
│ │ │ └────────┘ │ │ │
│ │ └─────────────┘ │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
### Key Files
| Layer | File | Purpose |
|-------|------|---------|
| Backend entry | `backend/main.py` | FastAPI app, all API routes (~1700 lines) |
| TTS protocol | `backend/backends/__init__.py:14-81` | `TTSBackend` Protocol definition |
| TTS factory | `backend/backends/__init__.py:118-137` | Singleton backend selection (MLX vs PyTorch) |
| PyTorch TTS | `backend/backends/pytorch_backend.py` | Qwen3-TTS via `qwen_tts` package |
| MLX TTS | `backend/backends/mlx_backend.py` | Qwen3-TTS via `mlx_audio.tts` |
| Platform detect | `backend/platform_detect.py` | Apple Silicon → MLX, else → PyTorch |
| API types | `backend/models.py` | Pydantic request/response models |
| Frontend API | `app/src/lib/api/client.ts` | Hand-written fetch wrapper |
| Frontend types | `app/src/lib/api/types.ts` | TypeScript API types |
| Generation form | `app/src/components/Generation/GenerationForm.tsx` | TTS generation UI |
| Model manager | `app/src/components/ServerSettings/ModelManagement.tsx` | Model download/status UI |
| Gen form hook | `app/src/lib/hooks/useGenerationForm.ts` | Form validation + submission |
### How TTS Generation Works (Current Flow)
```
POST /generate
1. Look up voice profile from DB
2. Check model cache → if missing, trigger background download, return HTTP 202
3. Load model (lazy): tts_backend.load_model(model_size)
4. Create voice prompt: profiles.create_voice_prompt_for_profile()
→ tts_backend.create_voice_prompt(audio_path, reference_text)
5. Generate: tts_backend.generate(text, voice_prompt, language, seed, instruct)
6. Save WAV → data/generations/{id}.wav
7. Insert history record in SQLite
8. Return GenerationResponse
```
---
## Current State
### What's Shipped (v0.1.13)
- Qwen3-TTS voice cloning (1.7B and 0.6B models)
- MLX backend for Apple Silicon, PyTorch for everything else
- Voice profiles with multi-sample support
- Stories editor (multi-track DAW timeline)
- Whisper transcription (base, small, medium, large variants)
- Model management UI with download progress (SSE)
- Generation history with caching
- Streaming generation endpoint (MLX only)
- Delivery instructions (instruct parameter)
### What's NOT Shipped But Has Code
| Feature | Branch | Status |
|---------|--------|--------|
| External provider binaries (CUDA split) | `external-provider-binaries` | PR #33, significant work done, stale since Feb |
| Dual server binaries | `feat/dual-server-binaries` | Branch exists, no PR |
| Multi-sample fix | `fix-multi-sample` | Branch exists, no PR |
| Model download notification fix | `fix-dl-notification-...` | Branch exists, no PR |
### Hardcoded Qwen3-TTS Assumptions
These are the specific coupling points that block multi-model support:
| Location | What's Hardcoded |
|----------|-----------------|
| `backend/models.py:58` | `model_size` regex: `^(1\.7B\|0\.6B)$` |
| `backend/main.py:611` | Default: `model_size or "1.7B"` |
| `backend/main.py:1322-1365` | Model status list (2 Qwen + 4 Whisper) |
| `backend/main.py:1523-1548` | Download trigger map |
| `backend/main.py:1597-1628` | Delete map |
| `backend/backends/pytorch_backend.py:65-68` | HF repo ID map |
| `backend/backends/mlx_backend.py:41-44` | MLX repo ID map |
| `backend/backends/__init__.py:118-137` | Single global TTS backend |
| `app/src/lib/hooks/useGenerationForm.ts:17` | `modelSize: z.enum(['1.7B', '0.6B'])` |
| `app/src/lib/hooks/useGenerationForm.ts:70-71` | `modelName = "qwen-tts-${data.modelSize}"` |
| `app/src/components/Generation/GenerationForm.tsx:140-141` | Hardcoded "Qwen TTS" labels |
| `app/src/components/ServerSettings/ModelManagement.tsx:166-213` | Filters by `qwen-tts` and `whisper` prefix |
| `backend/utils/cache.py` | Voice prompt cache uses `torch.save()` |
---
## Open PRs — Triage & Analysis
### Merge-Ready / Near-Ready (Bug Fixes & Small Features)
| PR | Title | Risk | Notes |
|----|-------|------|-------|
| **#250** | docs: align local API port examples | None | Docs-only |
| **#230** | docs: fix README grammar | None | Docs-only |
| **#243** | a11y: screen reader and keyboard improvements | Low | Accessibility, no backend changes |
| **#175** | Fix #134: duplicate profile name validation | Low | Simple validation |
| **#178** | Fix #168 #140: generation error handling | Low | Error handling improvements |
| **#152** | Fix: prevent crashes when HuggingFace unreachable | Medium | Monkey-patches HF hub; solves real offline bug (#150, #151) |
| **#218** | fix: unify qwen tts cache dir on Windows | Low | Windows-specific path fix |
| **#214** | fix: panic on launch from tokio::spawn | Low | Rust-side Tauri fix |
| **#210** | fix: Linux NVIDIA GBM buffer crash | Low | Linux-specific, narrowly scoped |
| **#88** | security: restrict CORS to known local origins | Low | Security hardening |
### Significant Feature PRs
| PR | Title | Complexity | Dependencies | Notes |
|----|-------|-----------|--------------|-------|
| **#97** | fix: pass language parameter to TTS models | Medium | None | **Critical bug** — language param was silently dropped. Adds `LANGUAGE_CODE_TO_NAME` mapping to both backends. Should be high priority. |
| **#133** | feat: network access toggle | Low | None | Wires up existing plumbing (`--host 0.0.0.0`). Clean, small. |
| **#238** | download cancel/clear UI + error panel | Medium | None | Adds cancel buttons, VS Code-style Problems panel, fixes whisper-large repo. Quality-of-life win. |
| **#99** | feat: chunked TTS with quality selector | Medium | None | Solves the 500-char/2048-token limit. Sentence-aware splitting, crossfade concat, 44.1kHz upsampling. Addresses #191, #203, #69, #111. |
| **#154** | feat: Audiobook tab | Medium | Depends on #99 concepts | Full audiobook workflow — chunked gen, preview, auto-save to Stories. New route + tab. |
| **#91** | fix: CoreAudio device enumeration | Medium | None | macOS audio device handling. |
### Architectural PRs (Need Careful Review)
| PR | Title | Complexity | Notes |
|----|-------|-----------|-------|
| **#33** | CUDA GPU Support — External Provider Binaries | **Very High** | The big one. Splits monolithic backend into main app + downloadable provider executables (PyTorch CPU, CUDA). New provider management system, CI/CD for R2 uploads, provider settings UI. Created Feb 1, significant codebase. **This is the foundation for multi-model support** but is currently Qwen-only. |
| **#225** | feat: custom HuggingFace model support | High | Adds `custom_models.py`, `custom:<slug>` model IDs, frontend model grouping (Built-in vs Custom). **Takes a different approach than #33** — keeps single backend but allows arbitrary HF repos. These two PRs may conflict architecturally. |
| **#194** | feat: Hebrew + Chatterbox TTS | High | **First non-Qwen TTS model.** Adds `ChatterboxTTSBackend` alongside existing backends. Routes by language (`he` → Chatterbox, else → Qwen). Adds Hebrew Whisper models. Includes a lot of cleanup. Important precedent for multi-model. |
| **#195** | feat: per-profile LoRA fine-tuning | **Very High** | Depends on #194. Training pipeline, adapter management, SSE progress, 15 new API endpoints. New DB tables. Forces PyTorch even on MLX systems for adapter inference. |
| **#161** | feat: Docker + web deployment | High | 3-stage Dockerfile, SPA serving from FastAPI, docker-compose. Implements the Docker deployment plan. |
| **#124** | Add Dockerfiles + docker-compose + docs | Medium | Earlier, simpler Docker attempt. Overlaps with #161. |
| **#123** | added docker | Low | Minimal Docker PR. Overlaps with #161 and #124. |
| **#227** | fix: harden input validation & file safety | Medium | Follow-up to #225. Atomic writes, threading locks, input validation. Good hardening but coupled to the custom models feature. |
### PRs That Need Author Action / Are Stale
| PR | Title | Notes |
|----|-------|-------|
| **#237** | fix: bundle qwen_tts source files in PyInstaller | Solves #212 but needs review for build system impact |
| **#215** | Update prerequisites with Tauri deps | Branch is `main` — will have conflicts |
| **#89** | Linux Support | Branch is `main` — will have conflicts. Broad scope. |
| **#83** | Update download links for v0.1.12 | Outdated (we're on v0.1.13) |
---
## Open Issues — Categorized
### GPU / Hardware Detection (19 issues)
The single most reported category. Users on Windows with NVIDIA GPUs frequently report "GPU not detected."
**Root causes (likely):**
- PyInstaller binary doesn't bundle CUDA correctly → falls back to CPU
- DirectML/Vulkan path not implemented (AMD on Windows)
- Binary size limit means CUDA can't ship in the main release
**Key issues:** #239, #222, #220, #217, #208, #198, #192, #167, #164, #141, #130, #127
**Fix path:** PR #33 (external provider binaries) is designed to solve this. Ship a small main app, let users download the CUDA provider separately.
### Model Downloads (20 issues)
Second most reported. Users get stuck downloads, can't resume, no cancel button, no offline fallback.
**Key issues:** #249, #240, #221, #216, #212, #181, #180, #159, #150, #149, #145, #143, #135, #134
**Fix path:** PR #238 (cancel/clear UI), PR #152 (offline crash fix). Resume support not yet addressed.
### Language Requests (18 issues)
Strong demand for: Hindi (#245), Indonesian (#247), Dutch (#236), Hebrew (#199), Greek (#188), Portuguese (#183), Persian (#162), and many more.
**Key issues:** #247, #245, #236, #211, #205, #199, #189, #188, #187, #183, #179, #162
**Fix path:** PR #97 (pass language param — currently silently dropped!) is the prerequisite. Qwen3-TTS already supports many languages; the bug is that the language code isn't forwarded. Multi-model (#194 Chatterbox for Hebrew) expands coverage further.
### New Model Requests (5 explicit issues)
| Issue | Model Requested |
|-------|----------------|
| #226 | GGUF support |
| #172 | VibeVoice |
| #138 | Export to ONNX/Piper format |
| #132 | LavaSR (transcription) |
| #76 | (General model expansion) |
Community is also vocally requesting: LuxTTS, Chatterbox, XTTS-v2, Fish Speech, CosyVoice, Kokoro on social media and in issue comments.
### Long-Form / Chunking (5 issues)
Users hitting the ~500 character practical limit.
**Key issues:** #234 (queue system), #203 (500 char limit), #191 (auto-split), #111, #69
**Fix path:** PR #99 (chunked TTS + quality selector) directly addresses this. PR #154 (Audiobook tab) builds on it.
### Feature Requests (23 issues)
Notable requests:
- **#234** — Queue system for batch generation
- **#182** — Concurrent/multi-thread generation
- **#173** — Vocal intonation/inflection control
- **#165** — Audiobook mode
- **#144** — Copy text to clipboard
- **#184** — Cancel button for progress bar
- **#242** — Seed value pinning for consistency
- **#228** — Always use 0.6B option
- **#233** — Transcribe audio API improvements
- **#235** — Finetuned Qwen3-TTS tokenizer
### Bugs (19 issues)
| Category | Issues |
|----------|--------|
| Generation failures | #248 (broken pipe), #219 (unsupported scalarType), #202 (clipping error), #170 (load failed) |
| UI bugs | #231 (history not updating), #190 (mobile landing), #169 (blank interface) |
| File operations | #207 (transcribe file error), #168 (no such file), #142 (download audio fail) |
| Server lifecycle | #166 (server processes remain), #164 (no auto-update) |
| Database | #174 (sqlite3 IntegrityError) |
| Dependency | #131 (numpy ABI mismatch), #209 (import error) |
---
## Existing Plan Documents — Status
| Document | Target Version | Status | Relevance |
|----------|---------------|--------|-----------|
| `TTS_PROVIDER_ARCHITECTURE.md` | v0.1.13 | **Partially implemented** in PR #33 | Core architecture for multi-model + CUDA distribution |
| `EXTERNAL_PROVIDERS.md` | v0.2.0 | **Not started** | Remote server support. API path inconsistency with provider arch doc (`/v1/` vs `/tts/`) |
| `MLX_AUDIO.md` | — | **Shipped** (the only one) | MLX backend is live. 0.6B MLX model still missing. |
| `DOCKER_DEPLOYMENT.md` | v0.2.0 | **PR exists** (#161) | Waiting on review. No official images published. |
| `OPENAI_SUPPORT.md` | v0.2.0 | **Not started** | OpenAI-compatible API layer. Linked to issue #10. Low complexity. |
### Cross-Document Conflicts
1. **API path inconsistency:** Provider arch uses `/tts/generate`, External providers uses `/v1/generate`, OpenAI compat uses `/v1/audio/speech`. Need to reconcile.
2. **Docker vs. Provider split:** Docker doc assumes monolithic backend. Provider arch splits into separate binaries. Need to decide: does Docker run the monolith or individual providers?
3. **Version targeting:** Provider arch targets v0.1.13 (current!) but isn't merged. Everything else targets v0.2.0.
---
## New Model Integration — Landscape
### Models Worth Supporting (2026 SOTA)
| Model | Cloning | Speed | Sample Rate | Languages | VRAM | Integration Ease | Repo |
|-------|---------|-------|-------------|-----------|------|-----------------|------|
| **LuxTTS** | 3s zero-shot | 150x RT, CPU ok | 48 kHz | English-first | <1 GB | Easy | `ysharma3501/LuxTTS` |
| **Chatterbox** | 5s zero-shot | Sub-200ms streaming | 24-48 kHz | 23+ | Low | Medium | `resemble-ai/chatterbox` |
| **XTTS-v2** | 6s zero-shot | Fast mid-GPU | 24 kHz | 17+ | Medium | Medium | `coqui/XTTS-v2` |
| **Fish Speech** | 10-30s few-shot | Real-time | 24-44 kHz | 50+ | Medium | Medium | `fishaudio/fish-speech` |
| **CosyVoice2-0.5B** | 3-10s zero-shot | Very fast | 24 kHz | Multilingual | Low | Easy | Alibaba HF org |
| **Kokoro-82M** | 3s instant | CPU realtime | 24 kHz | English | Tiny | Medium | Kokoro repo |
### What's Needed Architecturally for Multi-Model
The current codebase assumes one TTS model family (Qwen3-TTS). Adding any new model requires:
1. **Model type concept** — A `model_type` field (e.g. `qwen`, `luxtts`, `chatterbox`) alongside `model_size`. The `GenerationRequest` schema, frontend form, and all model config dicts need updating.
2. **Multiple backend instances** — The singleton `get_tts_backend()` needs to become a registry. Different models have different voice prompt formats, different inference APIs, different sample rates.
3. **Voice prompt format abstraction** — Qwen uses `torch.save()`-serialized tensors. LuxTTS uses `encode_prompt()` returning its own format. Chatterbox uses audio-path-based cloning. The cache system (`backend/utils/cache.py`) needs to handle heterogeneous formats.
4. **Sample rate normalization** — Qwen outputs 24 kHz. LuxTTS outputs 48 kHz. The Stories editor and audio pipeline need to handle mixed rates.
5. **Per-model capabilities** — Not all models support `instruct` (delivery instructions), not all support streaming, not all support the same languages. The UI needs to adapt.
### PR #194 as Precedent
The Hebrew/Chatterbox PR (#194) is the first attempt at multi-model. It takes a pragmatic approach: route by language (`he` → Chatterbox, else → Qwen). This works for one extra model but doesn't scale — what happens when you want Chatterbox for English too?
### PR #225 as Alternative Approach
The custom HuggingFace models PR (#225) takes a different angle: let users register arbitrary HF repos and attempt to load them through the existing Qwen backend. This is flexible but fragile — it assumes all models have the same API as Qwen3-TTS.
### PR #33 as Foundation
The external provider binaries PR (#33) has the most robust architecture for multi-model, since each provider is a separate process with its own dependencies. But it's complex, currently Qwen-only, and has been stale since early February.
---
## Architectural Bottlenecks
### 1. Single Backend Singleton
**File:** `backend/backends/__init__.py:118-137`
The entire TTS system runs through one global `_tts_backend` instance. You literally cannot have two models loaded. This is the #1 blocker for multi-model support.
### 2. `main.py` is 1700+ Lines
All API routes, all model configs, all business logic in one file. Three separate hardcoded model config dicts that must stay in sync. Any multi-model change touches this file heavily.
### 3. Model Config is Scattered
Model identifiers, HF repo IDs, display names, and download logic are duplicated across:
- `main.py` (3 separate dicts)
- `pytorch_backend.py` (HF repo map)
- `mlx_backend.py` (MLX repo map)
- `GenerationForm.tsx` (UI labels)
- `useGenerationForm.ts` (validation schema)
- `ModelManagement.tsx` (prefix filters)
There is no single source of truth for "what models does Voicebox support."
### 4. Voice Prompt Cache Assumes PyTorch Tensors
`backend/utils/cache.py` uses `torch.save()` / `torch.load()` for caching voice prompts. Models that don't use PyTorch tensors (LuxTTS, MLX-native models) can't use this cache.
### 5. Frontend Assumes Qwen Model Sizes
The generation form schema (`useGenerationForm.ts:17`) validates `model_size` as `'1.7B' | '0.6B'`. The model management UI filters by string prefix `qwen-tts`. Adding any model requires touching 3-4 frontend files.
---
## Recommended Priorities
### Tier 1 — Ship Now (Bug Fixes & Critical Improvements)
These PRs fix real user pain with low risk. Can be reviewed and merged quickly.
| Priority | PR | Impact | Effort |
|----------|-----|--------|--------|
| 1 | **#97** — Pass language param to TTS | Fixes all non-English generation (18 language issues) | Low |
| 2 | **#238** — Download cancel/clear UI | Addresses 20 download-related issues | Low |
| 3 | **#152** — Offline mode crash fix | Fixes #150, #151 | Low |
| 4 | **#99** — Chunked TTS + quality selector | Removes 500-char limit, addresses 5 issues | Medium |
| 5 | **#218** — Windows HF cache dir fix | Windows-specific pain | Low |
| 6 | **#175, #178** — Profile validation + error handling | Small fixes | Low |
| 7 | **#250, #230** — Docs fixes | Zero risk | None |
| 8 | **#133** — Network access toggle | Wires up existing code | Low |
| 9 | **#88** — CORS restriction | Security improvement | Low |
| 10 | **#214** — Tauri window close panic fix | Stability | Low |
### Tier 2 — Next Release (v0.2.0 Foundations)
These require more review but unlock major capabilities.
| Priority | Item | Impact | Effort | Dependencies |
|----------|------|--------|--------|-------------|
| 1 | **PR #33** — External provider binaries | Solves GPU distribution (19 issues), foundation for multi-model | Very High | Needs rebase, thorough review |
| 2 | **Multi-model abstraction layer** | Required before adding LuxTTS/Chatterbox/etc. | High | Informed by #33, #194, #225 |
| 3 | **PR #161** — Docker deployment | Server/headless users | Medium | Independent of #33 |
| 4 | **PR #194** — Hebrew + Chatterbox | First non-Qwen model, language expansion | High | Should align with multi-model abstraction |
| 5 | **PR #154** — Audiobook tab | Significant feature for long-form users | Medium | Benefits from #99 (chunking) |
### Tier 3 — Future (v0.3.0+)
| Item | Notes |
|------|-------|
| LuxTTS integration | 48 kHz, low VRAM, but needs multi-model arch first |
| XTTS-v2 / Fish Speech | Multilingual powerhouses |
| OpenAI-compatible API (plan doc exists) | Low effort once API is stable |
| LoRA fine-tuning (PR #195) | Complex, depends on #194 |
| External/remote providers (plan doc exists) | Depends on provider architecture |
| GGUF support (#226) | Depends on model ecosystem maturity |
| Queue system (#234) | Batch generation |
| Real-time streaming synthesis | MLX-only currently, needs PyTorch path |
### Decision Point: Multi-Model Architecture
Before adding any new TTS model, a decision is needed on *how*:
**Option A — Provider Binary Split (PR #33 approach)**
Each model family is a separate executable/process. Most isolated, most flexible, but most complex. Solves the CUDA distribution problem simultaneously.
**Option B — In-Process Model Registry**
Keep everything in one process but replace the singleton with a registry that can instantiate multiple `TTSBackend` implementations. Simpler, but doesn't solve binary size / CUDA distribution.
**Option C — Hybrid (Recommended)**
Use Option B for lightweight models (LuxTTS, Kokoro — small, CPU-friendly) that can coexist in-process. Use Option A for heavy models (CUDA Qwen3-TTS, Fish Speech) that need their own process/dependencies. The provider architecture from PR #33 becomes the escape hatch for heavy models, while light models are built-in.
This matches how PR #194 already works (Chatterbox loaded in-process alongside Qwen) while keeping the door open for PR #33's provider split.
---
## Branch Inventory
| Branch | PR | Status | Notes |
|--------|-----|--------|-------|
| `external-provider-binaries` | #33 | Open, stale | Major architecture work |
| `feat/dual-server-binaries` | — | No PR | Related to provider split? |
| `fix-multi-sample` | — | No PR | Voice profile multi-sample fix |
| `fix-dl-notification-...` | — | No PR | Model download UX |
| `improvements` | — | No PR | Unknown scope |
| `stories` | — | No PR | Stories editor work? |
| `windows-server-shutdown` | — | No PR | Windows lifecycle |
| `model-dl-fix` | — | No PR | Model download fix |
| `channels` | — | No PR | Audio channels |
| `audio-export-entitlement-fix` | — | No PR | macOS entitlements |
| `better-docs` | — | No PR | Documentation |
---
## Quick Reference: API Endpoints
<details>
<summary>All current endpoints (v0.1.13)</summary>
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/health` | GET | Health check, model/GPU status |
| `/profiles` | POST, GET | Create/list voice profiles |
| `/profiles/{id}` | GET, PUT, DELETE | Profile CRUD |
| `/profiles/{id}/samples` | POST, GET | Add/list voice samples |
| `/profiles/{id}/avatar` | POST, GET, DELETE | Avatar management |
| `/profiles/{id}/export` | GET | Export profile as ZIP |
| `/profiles/import` | POST | Import profile from ZIP |
| `/generate` | POST | Generate speech |
| `/generate/stream` | POST | Stream speech (SSE) |
| `/history` | GET | List generation history |
| `/history/{id}` | GET, DELETE | Get/delete generation |
| `/history/{id}/export` | GET | Export generation ZIP |
| `/history/{id}/export-audio` | GET | Export audio only |
| `/transcribe` | POST | Transcribe audio (Whisper) |
| `/models/status` | GET | All model statuses |
| `/models/download` | POST | Trigger model download |
| `/models/{name}` | DELETE | Delete downloaded model |
| `/models/load` | POST | Load model into memory |
| `/models/unload` | POST | Unload model |
| `/models/progress/{name}` | GET | SSE download progress |
| `/tasks/active` | GET | Active downloads/generations |
| `/stories` | POST, GET | Create/list stories |
| `/stories/{id}` | GET, PUT, DELETE | Story CRUD |
| `/stories/{id}/items` | POST, GET | Story items CRUD |
| `/stories/{id}/export` | GET | Export story audio |
| `/channels` | POST, GET | Audio channel CRUD |
| `/channels/{id}` | PUT, DELETE | Channel update/delete |
| `/cache/clear` | POST | Clear voice prompt cache |
</details>
+189
View File
@@ -0,0 +1,189 @@
# Voicebox development commands
# Install: brew install just (or cargo install just)
# Usage: just --list
# Directories
backend_dir := "backend"
tauri_dir := "tauri"
app_dir := "app"
web_dir := "web"
venv := backend_dir / "venv"
venv_bin := venv / "bin"
python := venv_bin / "python"
pip := venv_bin / "pip"
# Detect best python for venv creation
system_python := `command -v python3.12 2>/dev/null || command -v python3.13 2>/dev/null || echo python3`
# ─── Setup ────────────────────────────────────────────────────────────
# Full project setup (python venv + JS deps + dev sidecar)
setup: setup-python setup-js
@echo ""
@echo "Setup complete! Run: just dev"
# Create venv and install Python dependencies
setup-python:
#!/usr/bin/env bash
set -euo pipefail
if [ ! -d "{{ venv }}" ]; then
echo "Creating Python virtual environment..."
PY_MINOR=$({{ system_python }} -c "import sys; print(sys.version_info[1])")
if [ "$PY_MINOR" -gt 13 ]; then
echo "Warning: Python 3.$PY_MINOR detected. ML packages may not be compatible."
echo "Recommended: brew install [email protected]"
fi
{{ system_python }} -m venv {{ venv }}
fi
echo "Installing Python dependencies..."
{{ pip }} install --upgrade pip -q
{{ pip }} install -r {{ backend_dir }}/requirements.txt
# Apple Silicon: install MLX backend
if [ "$(uname -m)" = "arm64" ] && [ "$(uname)" = "Darwin" ]; then
echo "Detected Apple Silicon — installing MLX dependencies..."
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
fi
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
echo "Python environment ready."
# Install JavaScript dependencies
setup-js:
bun install
# ─── Development ──────────────────────────────────────────────────────
# Start backend + frontend for development (two processes, one terminal)
dev: _ensure-venv _ensure-sidecar
#!/usr/bin/env bash
set -euo pipefail
trap 'kill 0' EXIT
echo "Starting backend on http://localhost:17493 ..."
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
sleep 2
echo "Starting Tauri desktop app..."
cd {{ tauri_dir }} && bun run tauri dev &
wait
# Start backend only
dev-backend: _ensure-venv
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493
# Start Tauri desktop app only (backend must be running separately)
dev-frontend: _ensure-sidecar
cd {{ tauri_dir }} && bun run tauri dev
# Start backend + web app (no Tauri)
dev-web: _ensure-venv
#!/usr/bin/env bash
set -euo pipefail
trap 'kill 0' EXIT
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
sleep 2
cd {{ web_dir }} && bun run dev &
wait
# Kill all dev processes
kill:
-pkill -f "uvicorn backend.main:app" 2>/dev/null || true
-pkill -f "vite" 2>/dev/null || true
@echo "Dev processes killed."
# ─── Build ────────────────────────────────────────────────────────────
# Build everything (server binary + desktop app)
build: build-server build-tauri
# Build Python server binary
build-server: _ensure-venv
PATH="{{ venv_bin }}:$PATH" ./scripts/build-server.sh
# Build Tauri desktop app
build-tauri:
cd {{ tauri_dir }} && bun run tauri build
# Build web app
build-web:
cd {{ web_dir }} && bun run build
# ─── Code Quality ────────────────────────────────────────────────────
# Run all checks (lint + format + typecheck)
check:
bun run check
# Lint with Biome
lint:
bun run lint
# Format with Biome
format:
bun run format
# Fix lint + format issues
fix:
bun run check:fix
# ─── Database ─────────────────────────────────────────────────────────
# Initialize SQLite database
db-init: _ensure-venv
cd {{ backend_dir }} && {{ python }} -c "from database import init_db; init_db()"
# Reset database (delete + reinit)
db-reset:
rm -f {{ backend_dir }}/data/voicebox.db
just db-init
# ─── Utilities ────────────────────────────────────────────────────────
# Generate TypeScript API client (backend must be running)
generate-api:
./scripts/generate-api.sh
# Open API docs in browser
docs:
open http://localhost:17493/docs 2>/dev/null || xdg-open http://localhost:17493/docs
# Tail backend logs
logs:
tail -f {{ backend_dir }}/logs/*.log 2>/dev/null || echo "No log files found"
# ─── Clean ────────────────────────────────────────────────────────────
# Clean build artifacts
clean:
rm -rf {{ tauri_dir }}/src-tauri/target/release
rm -rf {{ web_dir }}/dist
rm -rf {{ app_dir }}/dist
# Clean Python venv and cache
clean-python:
rm -rf {{ venv }}
find {{ backend_dir }} -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
# Nuclear clean (everything including node_modules)
clean-all: clean clean-python
rm -rf node_modules
rm -rf {{ app_dir }}/node_modules
rm -rf {{ tauri_dir }}/node_modules
rm -rf {{ web_dir }}/node_modules
cd {{ tauri_dir }}/src-tauri && cargo clean
# ─── Internal ─────────────────────────────────────────────────────────
# Ensure venv exists (prompt to run setup if not)
[private]
_ensure-venv:
#!/usr/bin/env bash
if [ ! -d "{{ venv }}" ]; then
echo "Python venv not found. Run: just setup"
exit 1
fi
# Ensure Tauri dev sidecar placeholder exists
[private]
_ensure-sidecar:
bun run setup:dev
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@voicebox/landing",
"version": "0.1.12",
"version": "0.1.13",
"description": "Landing page for voicebox.sh",
"scripts": {
"dev": "bun --bun next dev --turbo",
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Banner } from '@/components/Banner';
import { Footer } from '@/components/Footer';
import { Header } from '@/components/Header';
@@ -31,6 +32,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="en" suppressHydrationWarning className="dark">
<body className={inter.variable}>
<div className="relative min-h-screen bg-background font-sans flex flex-col">
<Banner />
<Header />
<main className="container mx-auto px-4 sm:px-6 md:px-4 flex-1 py-4 sm:py-6 md:py-0">
{children}
+25
View File
@@ -0,0 +1,25 @@
import { ArrowRight } from 'lucide-react';
export function Banner() {
return (
<div className="bg-primary/[0.06] border-b border-border backdrop-blur-sm">
<div className="container mx-auto px-4">
<div className="flex items-center justify-center h-10 text-sm">
<a
href="https://spacebot.sh"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors group"
>
<span>
Also by the creator of Voicebox:{' '}
<strong className="text-foreground/90">Spacebot</strong>, an AI agent OS for teams.
Connect Discord, Slack, or Telegram in one click.
</span>
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
</a>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "voicebox",
"version": "0.1.12",
"version": "0.1.13",
"private": true,
"workspaces": [
"app",
+9
View File
@@ -0,0 +1,9 @@
uvicorn
fastapi
sqlalchemy
torch
torchvision
soundfile
librosa
python-multipart
huggingface_hub
+271 -56
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node
/**
* Creates placeholder sidecar binaries for development mode.
*
@@ -9,10 +10,10 @@
* The actual server should be started separately with `bun run dev:server`.
*/
import { existsSync, mkdirSync, writeFileSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import { existsSync, mkdirSync, statSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -55,7 +56,9 @@ function createPlaceholderBinary(targetTriple) {
try {
const stats = statSync(binaryPath);
if (stats.size > MIN_REAL_BINARY_SIZE) {
console.log(`Real binary already exists: ${binaryName} (${(stats.size / 1024 / 1024).toFixed(1)} MB)`);
console.log(
`Real binary already exists: ${binaryName} (${(stats.size / 1024 / 1024).toFixed(1)} MB)`,
);
return;
}
} catch {
@@ -73,52 +76,275 @@ function createPlaceholderBinary(targetTriple) {
// This is the smallest valid PE that Windows will accept
const minimalPE = Buffer.from([
// DOS Header
0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00,
0xB8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00,
0x4d,
0x5a,
0x90,
0x00,
0x03,
0x00,
0x00,
0x00,
0x04,
0x00,
0x00,
0x00,
0xff,
0xff,
0x00,
0x00,
0xb8,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x40,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x80,
0x00,
0x00,
0x00,
// DOS Stub
0x0E, 0x1F, 0xBA, 0x0E, 0x00, 0xB4, 0x09, 0xCD, 0x21, 0xB8, 0x01, 0x4C, 0xCD, 0x21, 0x54, 0x68,
0x69, 0x73, 0x20, 0x70, 0x72, 0x6F, 0x67, 0x72, 0x61, 0x6D, 0x20, 0x63, 0x61, 0x6E, 0x6E, 0x6F,
0x74, 0x20, 0x62, 0x65, 0x20, 0x72, 0x75, 0x6E, 0x20, 0x69, 0x6E, 0x20, 0x44, 0x4F, 0x53, 0x20,
0x6D, 0x6F, 0x64, 0x65, 0x2E, 0x0D, 0x0D, 0x0A, 0x24, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x0e,
0x1f,
0xba,
0x0e,
0x00,
0xb4,
0x09,
0xcd,
0x21,
0xb8,
0x01,
0x4c,
0xcd,
0x21,
0x54,
0x68,
0x69,
0x73,
0x20,
0x70,
0x72,
0x6f,
0x67,
0x72,
0x61,
0x6d,
0x20,
0x63,
0x61,
0x6e,
0x6e,
0x6f,
0x74,
0x20,
0x62,
0x65,
0x20,
0x72,
0x75,
0x6e,
0x20,
0x69,
0x6e,
0x20,
0x44,
0x4f,
0x53,
0x20,
0x6d,
0x6f,
0x64,
0x65,
0x2e,
0x0d,
0x0d,
0x0a,
0x24,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
// PE Signature
0x50, 0x45, 0x00, 0x00,
0x50,
0x45,
0x00,
0x00,
// COFF Header (x64)
0x64, 0x86, // Machine: AMD64
0x01, 0x00, // NumberOfSections: 1
0x00, 0x00, 0x00, 0x00, // TimeDateStamp
0x00, 0x00, 0x00, 0x00, // PointerToSymbolTable
0x00, 0x00, 0x00, 0x00, // NumberOfSymbols
0xF0, 0x00, // SizeOfOptionalHeader
0x22, 0x00, // Characteristics: EXECUTABLE_IMAGE | LARGE_ADDRESS_AWARE
0x64,
0x86, // Machine: AMD64
0x01,
0x00, // NumberOfSections: 1
0x00,
0x00,
0x00,
0x00, // TimeDateStamp
0x00,
0x00,
0x00,
0x00, // PointerToSymbolTable
0x00,
0x00,
0x00,
0x00, // NumberOfSymbols
0xf0,
0x00, // SizeOfOptionalHeader
0x22,
0x00, // Characteristics: EXECUTABLE_IMAGE | LARGE_ADDRESS_AWARE
// Optional Header (PE32+)
0x0B, 0x02, // Magic: PE32+
0x00, 0x00, // Linker version
0x00, 0x00, 0x00, 0x00, // SizeOfCode
0x00, 0x00, 0x00, 0x00, // SizeOfInitializedData
0x00, 0x00, 0x00, 0x00, // SizeOfUninitializedData
0x00, 0x10, 0x00, 0x00, // AddressOfEntryPoint
0x00, 0x00, 0x00, 0x00, // BaseOfCode
0x00, 0x00, 0x00, 0x40, 0x01, 0x00, 0x00, 0x00, // ImageBase
0x00, 0x10, 0x00, 0x00, // SectionAlignment
0x00, 0x02, 0x00, 0x00, // FileAlignment
0x06, 0x00, 0x00, 0x00, // OS version
0x00, 0x00, 0x00, 0x00, // Image version
0x06, 0x00, 0x00, 0x00, // Subsystem version
0x00, 0x00, 0x00, 0x00, // Win32VersionValue
0x00, 0x20, 0x00, 0x00, // SizeOfImage
0x00, 0x02, 0x00, 0x00, // SizeOfHeaders
0x00, 0x00, 0x00, 0x00, // CheckSum
0x03, 0x00, // Subsystem: CONSOLE
0x60, 0x01, // DllCharacteristics
0x0b,
0x02, // Magic: PE32+
0x00,
0x00, // Linker version
0x00,
0x00,
0x00,
0x00, // SizeOfCode
0x00,
0x00,
0x00,
0x00, // SizeOfInitializedData
0x00,
0x00,
0x00,
0x00, // SizeOfUninitializedData
0x00,
0x10,
0x00,
0x00, // AddressOfEntryPoint
0x00,
0x00,
0x00,
0x00, // BaseOfCode
0x00,
0x00,
0x00,
0x40,
0x01,
0x00,
0x00,
0x00, // ImageBase
0x00,
0x10,
0x00,
0x00, // SectionAlignment
0x00,
0x02,
0x00,
0x00, // FileAlignment
0x06,
0x00,
0x00,
0x00, // OS version
0x00,
0x00,
0x00,
0x00, // Image version
0x06,
0x00,
0x00,
0x00, // Subsystem version
0x00,
0x00,
0x00,
0x00, // Win32VersionValue
0x00,
0x20,
0x00,
0x00, // SizeOfImage
0x00,
0x02,
0x00,
0x00, // SizeOfHeaders
0x00,
0x00,
0x00,
0x00, // CheckSum
0x03,
0x00, // Subsystem: CONSOLE
0x60,
0x01, // DllCharacteristics
// Stack/Heap sizes (8 bytes each for PE32+)
0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, // LoaderFlags
0x10, 0x00, 0x00, 0x00, // NumberOfRvaAndSizes
0x00,
0x00,
0x10,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x10,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00, // LoaderFlags
0x10,
0x00,
0x00,
0x00, // NumberOfRvaAndSizes
]);
// Pad to 512 bytes minimum for valid PE
@@ -138,19 +364,8 @@ exit 1
}
function main() {
console.log('Setting up development sidecar...');
console.log('');
const targetTriple = getTargetTriple();
console.log(`Platform: ${targetTriple}`);
createPlaceholderBinary(targetTriple);
console.log('');
console.log('Sidecar setup complete.');
console.log('For development, start the Python server in a separate terminal:');
console.log(' bun run dev:server');
console.log('');
}
main();
+82
View File
@@ -0,0 +1,82 @@
"""
Split a large binary into chunks for GitHub Releases (<2 GB each).
Usage:
python scripts/split_binary.py backend/dist/voicebox-server-cuda.exe
python scripts/split_binary.py backend/dist/voicebox-server-cuda.exe --chunk-size 1900000000
python scripts/split_binary.py backend/dist/voicebox-server-cuda.exe --output release-assets/
The script produces:
- voicebox-server-cuda.part00.exe, .part01.exe, ... (binary chunks)
- voicebox-server-cuda.sha256 (SHA-256 checksum of the complete file)
- voicebox-server-cuda.manifest (ordered list of part filenames)
"""
import argparse
import hashlib
import sys
from pathlib import Path
def split(input_path: Path, chunk_size: int, output_dir: Path):
output_dir.mkdir(parents=True, exist_ok=True)
data = input_path.read_bytes()
total_size = len(data)
# Write SHA-256 of the complete file
sha256 = hashlib.sha256(data).hexdigest()
checksum_file = output_dir / f"{input_path.stem}.sha256"
checksum_file.write_text(f"{sha256} {input_path.name}\n")
# Split into chunks
parts = []
for i in range(0, total_size, chunk_size):
part_index = len(parts)
part_name = f"{input_path.stem}.part{part_index:02d}{input_path.suffix}"
part_path = output_dir / part_name
part_path.write_bytes(data[i:i + chunk_size])
parts.append(part_name)
# Write manifest (ordered list of part filenames)
manifest_file = output_dir / f"{input_path.stem}.manifest"
manifest_file.write_text("\n".join(parts) + "\n")
print(f"Input: {input_path} ({total_size / (1024**3):.2f} GB)")
print(f"Output: {output_dir}/")
print(f"Parts: {len(parts)} (chunk size: {chunk_size / (1024**3):.2f} GB)")
print(f"SHA-256: {sha256}")
print(f"Manifest: {manifest_file.name}")
for p in parts:
size = (output_dir / p).stat().st_size
print(f" {p} ({size / (1024**3):.2f} GB)")
def main():
parser = argparse.ArgumentParser(
description="Split a large binary into chunks for GitHub Releases"
)
parser.add_argument("input", type=Path, help="Path to the binary file to split")
parser.add_argument(
"--chunk-size",
type=int,
default=1_900_000_000, # 1.9 GB — safely under 2 GB GitHub limit
help="Maximum chunk size in bytes (default: 1.9 GB)",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="Output directory (default: same directory as input)",
)
args = parser.parse_args()
if not args.input.exists():
print(f"Error: {args.input} does not exist", file=sys.stderr)
sys.exit(1)
output_dir = args.output or args.input.parent
split(args.input, args.chunk_size, output_dir)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@voicebox/tauri",
"private": true,
"version": "0.1.12",
"version": "0.1.13",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -5041,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voicebox"
version = "0.1.11"
version = "0.1.12"
dependencies = [
"base64 0.22.1",
"core-foundation-sys",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "voicebox"
version = "0.1.12"
version = "0.1.13"
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
authors = ["you"]
license = ""
+95 -15
View File
@@ -178,6 +178,56 @@ async fn start_server(
println!("Data directory: {:?}", data_dir);
println!("Remote mode: {}", remote.unwrap_or(false));
// Check for CUDA backend binary in data directory
let cuda_binary = {
let backends_dir = data_dir.join("backends");
let cuda_name = if cfg!(windows) {
"voicebox-server-cuda.exe"
} else {
"voicebox-server-cuda"
};
let path = backends_dir.join(cuda_name);
if path.exists() {
println!("Found CUDA backend binary at {:?}", path);
// Version check: run --version and compare to app version
let app_version = app.config().version.clone().unwrap_or_default();
let version_ok = match std::process::Command::new(&path)
.arg("--version")
.output()
{
Ok(output) => {
// Output format: "voicebox-server X.Y.Z\n"
let version_str = String::from_utf8_lossy(&output.stdout);
let binary_version = version_str.trim().split_whitespace().last().unwrap_or("");
if binary_version == app_version {
println!("CUDA binary version {} matches app version", binary_version);
true
} else {
println!(
"CUDA binary version mismatch: binary={}, app={}. Falling back to CPU.",
binary_version, app_version
);
false
}
}
Err(e) => {
println!("Failed to check CUDA binary version: {}. Falling back to CPU.", e);
false
}
};
if version_ok {
Some(path)
} else {
None
}
} else {
println!("No CUDA backend found, using bundled CPU binary");
None
}
};
let sidecar_result = app.shell().sidecar("voicebox-server");
let mut sidecar = match sidecar_result {
@@ -216,22 +266,32 @@ async fn start_server(
println!("Sidecar command created successfully");
// Pass data directory and port to Python server
sidecar = sidecar.args([
"--data-dir",
data_dir
.to_str()
.ok_or_else(|| "Invalid data dir path".to_string())?,
"--port",
&SERVER_PORT.to_string(),
]);
// Build common args
let data_dir_str = data_dir
.to_str()
.ok_or_else(|| "Invalid data dir path".to_string())?
.to_string();
let port_str = SERVER_PORT.to_string();
let is_remote = remote.unwrap_or(false);
if remote.unwrap_or(false) {
sidecar = sidecar.args(["--host", "0.0.0.0"]);
}
println!("Spawning server process...");
let spawn_result = sidecar.spawn();
// If CUDA binary exists, launch it directly instead of the bundled sidecar
let spawn_result = if let Some(ref cuda_path) = cuda_binary {
println!("Launching CUDA backend: {:?}", cuda_path);
let mut cmd = app.shell().command(cuda_path.to_str().unwrap());
cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str]);
if is_remote {
cmd = cmd.args(["--host", "0.0.0.0"]);
}
cmd.spawn()
} else {
// Use the bundled CPU sidecar
sidecar = sidecar.args(["--data-dir", &data_dir_str, "--port", &port_str]);
if is_remote {
sidecar = sidecar.args(["--host", "0.0.0.0"]);
}
println!("Spawning server process...");
sidecar.spawn()
};
let (mut rx, child) = match spawn_result {
Ok(result) => result,
@@ -549,6 +609,25 @@ async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
Ok(())
}
#[command]
async fn restart_server(
app: tauri::AppHandle,
state: State<'_, ServerState>,
) -> Result<String, String> {
println!("restart_server: stopping current server...");
// Stop the current server
stop_server(state.clone()).await?;
// Wait for port to be released
println!("restart_server: waiting for port release...");
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
// Start server again (will auto-detect CUDA binary)
println!("restart_server: starting server...");
start_server(app, state, None).await
}
#[command]
fn set_keep_server_running(state: State<'_, ServerState>, keep_running: bool) {
*state.keep_running_on_close.lock().unwrap() = keep_running;
@@ -640,6 +719,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
start_server,
stop_server,
restart_server,
set_keep_server_running,
start_system_audio_capture,
stop_system_audio_capture,
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Voicebox",
"version": "0.1.12",
"version": "0.1.13",
"identifier": "sh.voicebox.app",
"build": {
"beforeDevCommand": "bun run dev",
@@ -12,7 +12,7 @@
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"createUpdaterArtifacts": false,
"externalBin": ["binaries/voicebox-server"],
"icon": [
"icons/32x32.png",
+16 -31
View File
@@ -2,40 +2,25 @@ import type { PlatformFilesystem, FileFilter } from '@/platform/types';
export const tauriFilesystem: PlatformFilesystem = {
async saveFile(filename: string, blob: Blob, filters?: FileFilter[]) {
try {
const { save } = await import('@tauri-apps/plugin-dialog');
const filePath = await save({
defaultPath: filename,
filters: filters || [],
});
const { save } = await import('@tauri-apps/plugin-dialog');
const { writeFile } = await import('@tauri-apps/plugin-fs');
if (filePath) {
let resolvedPath = '';
if (typeof filePath === 'string') {
resolvedPath = filePath;
} else if (filePath && typeof filePath === 'object' && 'path' in filePath) {
resolvedPath = (filePath as { path: string }).path;
}
const filePath = await save({
defaultPath: filename,
filters: filters || [],
});
if (!resolvedPath) {
throw new Error('Failed to resolve save path');
}
if (!filePath) return; // User cancelled the dialog
const { writeFile } = await import('@tauri-apps/plugin-fs');
const arrayBuffer = await blob.arrayBuffer();
await writeFile(resolvedPath, new Uint8Array(arrayBuffer));
}
} catch (error) {
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
// Fall back to browser download if Tauri dialog fails
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
const resolvedPath = typeof filePath === 'string'
? filePath
: (filePath as { path: string }).path;
if (!resolvedPath) {
throw new Error('Failed to resolve save path from dialog');
}
const arrayBuffer = await blob.arrayBuffer();
await writeFile(resolvedPath, new Uint8Array(arrayBuffer));
},
};
+13 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
import { listen, emit } from '@tauri-apps/api/event';
import { emit, listen } from '@tauri-apps/api/event';
import type { PlatformLifecycle } from '@/platform/types';
class TauriLifecycle implements PlatformLifecycle {
@@ -27,6 +27,18 @@ class TauriLifecycle implements PlatformLifecycle {
}
}
async restartServer(): Promise<string> {
try {
const result = await invoke<string>('restart_server');
console.log('Server restarted:', result);
this.onServerReady?.();
return result;
} catch (error) {
console.error('Failed to restart server:', error);
throw error;
}
}
async setKeepServerRunning(keepRunning: boolean): Promise<void> {
try {
await invoke('set_keep_server_running', { keepRunning });
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@voicebox/web",
"private": true,
"version": "0.1.12",
"version": "0.1.13",
"type": "module",
"scripts": {
"dev": "vite",
@@ -21,6 +21,7 @@
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-react": "^4.3.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
+5
View File
@@ -15,6 +15,11 @@ class WebLifecycle implements PlatformLifecycle {
// No-op for web - server is managed externally
}
async restartServer(): Promise<string> {
// No-op for web - server is managed externally
return import.meta.env.VITE_SERVER_URL || 'http://localhost:17493';
}
async setKeepServerRunning(_keep: boolean): Promise<void> {
// No-op for web
}
+2 -1
View File
@@ -1,9 +1,10 @@
import path from 'node:path';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '../app/src'),