mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-27 06:05:14 -07:00
Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea41213123 | ||
|
|
3e6513c0fb | ||
|
|
c54ee14173 | ||
|
|
cc07d4d3c9 | ||
|
|
9beb9d7fec | ||
|
|
76bb207b2b | ||
|
|
3576521d62 | ||
|
|
2df4ece388 | ||
|
|
cbb4979ed6 | ||
|
|
753158c1c9 | ||
|
|
573f82a7e6 | ||
|
|
1e5afc2bef | ||
|
|
163528bf69 | ||
|
|
e1ad7a6e73 | ||
|
|
411e91bb19 | ||
|
|
05cf163744 | ||
|
|
d46eb5bcc6 | ||
|
|
6359dee406 | ||
|
|
a69c216794 | ||
|
|
2867421550 | ||
|
|
758577fd4b | ||
|
|
3d2506767d | ||
|
|
cdef2163c1 | ||
|
|
a8ecf3f31d | ||
|
|
d744e634a8 | ||
|
|
a362d7de2a | ||
|
|
30ee07c2e3 | ||
|
|
d21c63b52c | ||
|
|
5ad67d7ecb | ||
|
|
6cc96c2614 | ||
|
|
38bf96ff20 | ||
|
|
0b14cb1b2c | ||
|
|
4d24e69012 | ||
|
|
90436e428d | ||
|
|
e4bb288904 | ||
|
|
6f8bc7f23b | ||
|
|
baca111d50 | ||
|
|
cc298fe6d8 | ||
|
|
46b8f6b882 | ||
|
|
162cf4fb84 | ||
|
|
68558243d9 | ||
|
|
8d5ad926f9 | ||
|
|
334f037dce | ||
|
|
f6522eea80 | ||
|
|
7615a08f81 | ||
|
|
31ea3c68a5 | ||
|
|
54d72ddfd0 | ||
|
|
d4794f78e1 | ||
|
|
aa7c9a9a8d | ||
|
|
ca6ed0998a | ||
|
|
829d4d6d5b | ||
|
|
0be7975db5 | ||
|
|
40e4af828a | ||
|
|
0e57826ea5 | ||
|
|
eb2cd861b1 | ||
|
|
701cc647a7 | ||
|
|
be6ccaf044 | ||
|
|
1040625a88 | ||
|
|
6f4503b521 | ||
|
|
f5b6edc2e7 | ||
|
|
8197f0724c | ||
|
|
d40f7d2676 | ||
|
|
99fbcca7f4 | ||
|
|
04f9880c9a | ||
|
|
b9c858295d | ||
|
|
610f64c762 | ||
|
|
220333b3bb | ||
|
|
e194e95512 | ||
|
|
e796412c2c | ||
|
|
cb541521d2 | ||
|
|
2bc243f93e | ||
|
|
0209008d73 | ||
|
|
9bde534860 | ||
|
|
97eb570b28 | ||
|
|
7d0557a099 | ||
|
|
60a03c56a9 | ||
|
|
d3393fb940 | ||
|
|
07c0aba883 | ||
|
|
77418a52ae | ||
|
|
46f6806e14 | ||
|
|
20851ccc2b | ||
|
|
e5f4606a6c | ||
|
|
146ef5aaeb | ||
|
|
971604d14f | ||
|
|
0b17073345 | ||
|
|
17106b1e40 | ||
|
|
953e6ec7d8 | ||
|
|
d3c65fc6c2 | ||
|
|
7fcca09f24 | ||
|
|
b6e772c6ac | ||
|
|
a6b070201b | ||
|
|
30352e2419 | ||
|
|
bfa38b36b7 | ||
|
|
1b66a528d1 | ||
|
|
bef4092e6e | ||
|
|
9654f7b642 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.1.10
|
||||
current_version = 0.1.13
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
|
||||
@@ -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
|
||||
# ...
|
||||
@@ -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
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -14,22 +14,22 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target aarch64-apple-darwin'
|
||||
python-version: '3.12'
|
||||
backend: 'mlx'
|
||||
- platform: 'macos-15-intel'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
python-version: '3.12'
|
||||
backend: 'pytorch'
|
||||
- platform: "macos-latest"
|
||||
args: "--target aarch64-apple-darwin"
|
||||
python-version: "3.12"
|
||||
backend: "mlx"
|
||||
- platform: "macos-15-intel"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
python-version: "3.12"
|
||||
backend: "pytorch"
|
||||
# - platform: 'ubuntu-22.04'
|
||||
# args: ''
|
||||
# python-version: '3.12'
|
||||
# backend: 'pytorch'
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
python-version: '3.12'
|
||||
backend: 'pytorch'
|
||||
- platform: "windows-latest"
|
||||
args: ""
|
||||
python-version: "3.12"
|
||||
backend: "pytorch"
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
cache: "pip"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
@@ -66,6 +66,12 @@ jobs:
|
||||
run: |
|
||||
pip install -r backend/requirements-mlx.txt
|
||||
|
||||
# - name: Install PyTorch with CUDA (Windows only)
|
||||
# if: matrix.platform == 'windows-latest'
|
||||
# run: |
|
||||
# pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall --no-deps
|
||||
# pip install torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||
|
||||
- name: Build Python server (Linux/macOS)
|
||||
if: matrix.platform != 'windows-latest'
|
||||
run: |
|
||||
@@ -100,7 +106,7 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './tauri/src-tauri -> target'
|
||||
workspaces: "./tauri/src-tauri -> target"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
@@ -136,7 +142,7 @@ jobs:
|
||||
with:
|
||||
projectPath: tauri
|
||||
tagName: v__VERSION__
|
||||
releaseName: 'voicebox v__VERSION__'
|
||||
releaseName: "voicebox v__VERSION__"
|
||||
releaseBody: |
|
||||
## What's Changed
|
||||
See the assets below to download and install this version.
|
||||
|
||||
@@ -5,6 +5,14 @@ All notable changes to Voicebox will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Profile Name Validation** - Added proper validation to prevent duplicate profile names ([#134](https://github.com/jamiepine/voicebox/issues/134))
|
||||
- Users now receive clear error messages when attempting to create or update profiles with duplicate names
|
||||
- Improved error handling in create and update profile API endpoints
|
||||
- Added comprehensive test suite for duplicate name validation
|
||||
|
||||
## [0.1.0] - 2026-01-25
|
||||
|
||||
### Added
|
||||
@@ -53,6 +61,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Audio export failing when Tauri save dialog returns object instead of string path
|
||||
- OpenAPI client generator script now documents the local backend port and avoids an unused loop variable warning
|
||||
|
||||
### Added
|
||||
- **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
|
||||
- Includes Python version detection and compatibility warnings
|
||||
|
||||
+21
-2
@@ -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):**
|
||||
|
||||
@@ -407,7 +426,7 @@ See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and sol
|
||||
|
||||
- **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies
|
||||
- **Tauri build fails:** Ensure Rust is installed, clean build with `cd tauri/src-tauri && cargo clean`
|
||||
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:8000/openapi.json`
|
||||
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:17493/openapi.json`
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ setup-python: $(VENV)/bin/activate ## Set up Python virtual environment and depe
|
||||
@echo -e "$(BLUE)Installing Python dependencies...$(NC)"
|
||||
$(PIP) install --upgrade pip
|
||||
$(PIP) install -r $(BACKEND_DIR)/requirements.txt
|
||||
$(PIP) install --no-deps chatterbox-tts
|
||||
@if [ "$$(uname -m)" = "arm64" ] && [ "$$(uname)" = "Darwin" ]; then \
|
||||
echo -e "$(BLUE)Detected Apple Silicon - installing MLX dependencies...$(NC)"; \
|
||||
$(PIP) install -r $(BACKEND_DIR)/requirements-mlx.txt; \
|
||||
@@ -79,7 +80,11 @@ dev: ## Start backend + desktop app (parallel)
|
||||
@echo -e "$(YELLOW)Note: If Tauri fails, run 'make build-server' first or use separate terminals$(NC)"
|
||||
@trap 'kill 0' EXIT; \
|
||||
$(MAKE) dev-backend & \
|
||||
sleep 2 && $(MAKE) dev-frontend & \
|
||||
sleep 2 && if [ "$$(uname)" = "Linux" ] && lspci 2>/dev/null | grep -qi nvidia; then \
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 $(MAKE) dev-frontend; \
|
||||
else \
|
||||
$(MAKE) dev-frontend; \
|
||||
fi & \
|
||||
wait
|
||||
|
||||
dev-backend: ## Start FastAPI backend server
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
## What is Voicebox?
|
||||
|
||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as the **Ollama for voice** — download models, clone voices, and generate speech entirely on your machine.
|
||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as a **local, free and open-source alternative to ElevenLabs** — download models, clone voices, and generate speech entirely on your machine.
|
||||
|
||||
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives you:
|
||||
|
||||
@@ -68,6 +68,7 @@ Unlike cloud services that lock your voice data behind subscriptions, Voicebox g
|
||||
- **Model flexibility** — currently powered by Qwen3-TTS, with support for XTTS, Bark, and other models coming soon
|
||||
- **API-first** — use the desktop app or integrate voice synthesis into your own projects
|
||||
- **Native performance** — built with Tauri (Rust), not Electron
|
||||
- **Super fast on Mac** — MLX backend with native Metal acceleration for 4-5x faster inference on Apple Silicon
|
||||
|
||||
Download a voice model, clone any voice from a few seconds of audio, and compose multi-voice projects with studio-grade editing tools. No Python install required, no cloud dependency, no limits.
|
||||
|
||||
@@ -79,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.
|
||||
|
||||
@@ -97,6 +98,7 @@ Powered by Alibaba's **Qwen3-TTS** — a breakthrough model that achieves near-p
|
||||
- **Instant cloning** — Upload a sample, get a voice profile
|
||||
- **High fidelity** — Natural prosody, emotion, and cadence
|
||||
- **Multi-language** — English, Chinese, and more coming
|
||||
- **Lightning fast on Mac** — MLX backend leverages Apple Silicon's Neural Engine for super fast generation
|
||||
|
||||
### Voice Profile Management
|
||||
|
||||
@@ -145,17 +147,20 @@ Create multi-voice narratives, podcasts, and conversations with a timeline-based
|
||||
|
||||
Voicebox exposes a full REST API, so you can integrate voice synthesis into your own apps.
|
||||
|
||||
For the current local app and development workflow, the backend is typically available at `http://localhost:17493`.
|
||||
If you launch the backend manually with a different host or port, use that address instead.
|
||||
|
||||
```bash
|
||||
# Generate speech
|
||||
curl -X POST http://localhost:8000/generate \
|
||||
curl -X POST http://localhost:17493/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
|
||||
|
||||
# List voice profiles
|
||||
curl http://localhost:8000/profiles
|
||||
curl http://localhost:17493/profiles
|
||||
|
||||
# Create a profile
|
||||
curl -X POST http://localhost:8000/profiles \
|
||||
curl -X POST http://localhost:17493/profiles \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "My Voice", "language": "en"}'
|
||||
```
|
||||
@@ -168,7 +173,7 @@ curl -X POST http://localhost:8000/profiles \
|
||||
- Voice assistants
|
||||
- Content creation automation
|
||||
|
||||
Full API documentation available at `http://localhost:8000/docs` when running.
|
||||
Full API documentation is available at `http://localhost:17493/docs` in the default local workflow, or at `/docs` on whatever server address you configured.
|
||||
|
||||
---
|
||||
|
||||
@@ -223,42 +228,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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+34
-17
@@ -1,16 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import ShinyText from '@/components/ShinyText';
|
||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import {
|
||||
isTauri,
|
||||
setKeepServerRunning,
|
||||
setupWindowCloseHandler,
|
||||
startServer,
|
||||
} from '@/lib/tauri';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { router } from '@/router';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
@@ -38,29 +34,45 @@ const LOADING_MESSAGES = [
|
||||
];
|
||||
|
||||
function App() {
|
||||
const platform = usePlatform();
|
||||
const [serverReady, setServerReady] = useState(false);
|
||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||
const serverStartingRef = useRef(false);
|
||||
|
||||
// Automatically check for app updates on startup and show toast notifications
|
||||
useAutoUpdater({ checkOnMount: true, showToast: true });
|
||||
|
||||
// Sync stored setting to Rust on startup
|
||||
useEffect(() => {
|
||||
if (isTauri()) {
|
||||
if (platform.metadata.isTauri) {
|
||||
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
||||
setKeepServerRunning(keepRunning).catch((error) => {
|
||||
platform.lifecycle.setKeepServerRunning(keepRunning).catch((error) => {
|
||||
console.error('Failed to sync initial setting to Rust:', error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||
|
||||
// Setup lifecycle callbacks
|
||||
useEffect(() => {
|
||||
platform.lifecycle.onServerReady = () => {
|
||||
setServerReady(true);
|
||||
};
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.lifecycle]);
|
||||
|
||||
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||
useEffect(() => {
|
||||
if (!isTauri()) {
|
||||
if (!platform.metadata.isTauri) {
|
||||
setServerReady(true); // Web assumes server is running
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup window close handler to check setting and stop server if needed
|
||||
// This works in both dev and prod, but will only stop server if it was started by the app
|
||||
setupWindowCloseHandler().catch((error) => {
|
||||
platform.lifecycle.setupWindowCloseHandler().catch((error) => {
|
||||
console.error('Failed to setup window close handler:', error);
|
||||
});
|
||||
|
||||
@@ -83,18 +95,21 @@ function App() {
|
||||
serverStartingRef.current = true;
|
||||
console.log('Production mode: Starting bundled server...');
|
||||
|
||||
startServer(false)
|
||||
platform.lifecycle
|
||||
.startServer(false)
|
||||
.then((serverUrl) => {
|
||||
console.log('Server is ready at:', serverUrl);
|
||||
// Update the server URL in the store with the dynamically assigned port
|
||||
useServerStore.getState().setServerUrl(serverUrl);
|
||||
setServerReady(true);
|
||||
// Mark that we started the server (so we know to stop it on close)
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to auto-start server:', error);
|
||||
serverStartingRef.current = false;
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = false;
|
||||
});
|
||||
|
||||
@@ -104,11 +119,13 @@ function App() {
|
||||
// Window close event handles server shutdown based on setting
|
||||
serverStartingRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||
|
||||
// Cycle through loading messages every 3 seconds
|
||||
useEffect(() => {
|
||||
if (!isTauri() || serverReady) {
|
||||
if (!platform.metadata.isTauri || serverReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,10 +134,10 @@ function App() {
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [serverReady]);
|
||||
}, [serverReady, platform.metadata.isTauri]);
|
||||
|
||||
// Show loading screen while server is starting in Tauri
|
||||
if (isTauri() && !serverReady) {
|
||||
if (platform.metadata.isTauri && !serverReady) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
import { debug } from '@/lib/utils/debug';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function AudioPlayer() {
|
||||
const platform = usePlatform();
|
||||
const {
|
||||
audioUrl,
|
||||
audioId,
|
||||
@@ -39,7 +39,7 @@ export function AudioPlayer() {
|
||||
if (!profileId) return { channel_ids: [] };
|
||||
return apiClient.getProfileChannels(profileId);
|
||||
},
|
||||
enabled: !!profileId && isTauri(),
|
||||
enabled: !!profileId && platform.metadata.isTauri,
|
||||
});
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
@@ -50,7 +50,7 @@ export function AudioPlayer() {
|
||||
|
||||
// Determine if we should use native playback
|
||||
const useNativePlayback = useMemo(() => {
|
||||
if (!isTauri() || !profileChannels || !channels) {
|
||||
if (!platform.metadata.isTauri || !profileChannels || !channels) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ export function AudioPlayer() {
|
||||
let runtimeProfileChannels = null;
|
||||
let runtimeChannels = null;
|
||||
|
||||
if (isTauri() && currentProfileId) {
|
||||
if (platform.metadata.isTauri && currentProfileId) {
|
||||
try {
|
||||
runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
|
||||
debug.log('Runtime profileChannels:', runtimeProfileChannels);
|
||||
@@ -210,7 +210,7 @@ export function AudioPlayer() {
|
||||
}
|
||||
|
||||
debug.log('Auto-play check:', {
|
||||
isTauri: isTauri(),
|
||||
isTauri: platform.metadata.isTauri,
|
||||
currentAudioUrl,
|
||||
currentProfileId,
|
||||
hasProfileChannels: !!runtimeProfileChannels,
|
||||
@@ -218,7 +218,7 @@ export function AudioPlayer() {
|
||||
});
|
||||
|
||||
if (
|
||||
isTauri() &&
|
||||
platform.metadata.isTauri &&
|
||||
currentAudioUrl &&
|
||||
currentProfileId &&
|
||||
runtimeProfileChannels &&
|
||||
@@ -229,7 +229,7 @@ export function AudioPlayer() {
|
||||
// Stop any existing native playback first
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
try {
|
||||
await invoke('stop_audio_playback');
|
||||
platform.audio.stopPlayback();
|
||||
debug.log('Stopped existing native playback before starting new one');
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop existing playback:', error);
|
||||
@@ -279,11 +279,8 @@ export function AudioPlayer() {
|
||||
// Play via native audio
|
||||
debug.log('Invoking play_audio_to_devices...');
|
||||
try {
|
||||
const result = await invoke('play_audio_to_devices', {
|
||||
audioData: Array.from(audioData),
|
||||
deviceIds: deviceIds,
|
||||
});
|
||||
debug.log('play_audio_to_devices completed successfully, result:', result);
|
||||
await platform.audio.playToDevices(audioData, deviceIds);
|
||||
debug.log('play_audio_to_devices completed successfully');
|
||||
|
||||
// Mark that we're using native playback
|
||||
isUsingNativePlaybackRef.current = true;
|
||||
@@ -516,15 +513,13 @@ export function AudioPlayer() {
|
||||
}
|
||||
|
||||
// Stop native playback if it was active
|
||||
if (isUsingNativePlaybackRef.current && isTauri()) {
|
||||
(async () => {
|
||||
try {
|
||||
await invoke('stop_audio_playback');
|
||||
debug.log('Stopped native audio playback');
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop native playback:', error);
|
||||
}
|
||||
})();
|
||||
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
|
||||
try {
|
||||
platform.audio.stopPlayback();
|
||||
debug.log('Stopped native audio playback');
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop native playback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset native playback flag when loading new audio
|
||||
@@ -711,7 +706,7 @@ export function AudioPlayer() {
|
||||
if (isPlaying) {
|
||||
// Pause: stop native playback and pause WaveSurfer visualization
|
||||
try {
|
||||
await invoke('stop_audio_playback');
|
||||
platform.audio.stopPlayback();
|
||||
debug.log('Stopped native audio playback');
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop native playback:', error);
|
||||
@@ -724,7 +719,7 @@ export function AudioPlayer() {
|
||||
try {
|
||||
// Stop any existing native playback first
|
||||
try {
|
||||
await invoke('stop_audio_playback');
|
||||
platform.audio.stopPlayback();
|
||||
} catch (_error) {
|
||||
// Ignore errors when stopping (might not be playing)
|
||||
debug.log('No existing playback to stop');
|
||||
@@ -742,10 +737,7 @@ export function AudioPlayer() {
|
||||
const audioData = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
// Play via native audio
|
||||
await invoke('play_audio_to_devices', {
|
||||
audioData: Array.from(audioData),
|
||||
deviceIds: deviceIds,
|
||||
});
|
||||
await platform.audio.playToDevices(audioData, deviceIds);
|
||||
|
||||
// Mark that we're using native playback
|
||||
isUsingNativePlaybackRef.current = true;
|
||||
@@ -806,10 +798,12 @@ export function AudioPlayer() {
|
||||
|
||||
const handleClose = () => {
|
||||
// Stop any native playback
|
||||
if (isUsingNativePlaybackRef.current && isTauri()) {
|
||||
invoke('stop_audio_playback').catch((error) => {
|
||||
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
|
||||
try {
|
||||
platform.audio.stopPlayback();
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop native playback:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Stop WaveSurfer
|
||||
if (wavesurferRef.current) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -23,9 +22,9 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
interface AudioDevice {
|
||||
id: string;
|
||||
@@ -34,6 +33,7 @@ interface AudioDevice {
|
||||
}
|
||||
|
||||
export function AudioTab() {
|
||||
const platform = usePlatform();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
||||
@@ -49,18 +49,17 @@ export function AudioTab() {
|
||||
const { data: devices, isLoading: devicesLoading } = useQuery({
|
||||
queryKey: ['audio-devices'],
|
||||
queryFn: async () => {
|
||||
if (!isTauri()) {
|
||||
if (!platform.metadata.isTauri) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const result = await invoke<AudioDevice[]>('list_audio_output_devices');
|
||||
return result;
|
||||
return await platform.audio.listOutputDevices();
|
||||
} catch (error) {
|
||||
console.error('Failed to list audio devices:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: isTauri(),
|
||||
enabled: platform.metadata.isTauri,
|
||||
});
|
||||
|
||||
const { data: profiles } = useQuery({
|
||||
@@ -125,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
|
||||
@@ -242,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>
|
||||
@@ -342,7 +343,7 @@ export function AudioTab() {
|
||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
{isTauri() ? 'No audio devices found' : 'Audio device selection requires Tauri'}
|
||||
{platform.metadata.isTauri ? 'No audio devices found' : 'Audio device selection requires Tauri'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMatchRoute } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Loader2, MessageSquare, Sparkles } from 'lucide-react';
|
||||
import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
@@ -187,7 +187,7 @@ export function FloatingGenerateBox({
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 overflow-hidden p-3"
|
||||
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 p-3"
|
||||
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
||||
>
|
||||
<Form {...form}>
|
||||
@@ -274,7 +274,7 @@ export function FloatingGenerateBox({
|
||||
field.ref(node);
|
||||
}
|
||||
}}
|
||||
placeholder="Add delivery instructions..."
|
||||
placeholder="e.g. very happy and excited"
|
||||
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
||||
style={{
|
||||
minHeight: isExpanded ? '100px' : '32px',
|
||||
@@ -294,20 +294,29 @@ export function FloatingGenerateBox({
|
||||
</motion.div>
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !selectedProfileId}
|
||||
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
|
||||
size="icon"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !selectedProfileId}
|
||||
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
|
||||
size="icon"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
{isPending
|
||||
? 'Generating...'
|
||||
: !selectedProfileId
|
||||
? 'Select a voice profile first'
|
||||
: 'Generate speech'}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
{isExpanded && form.watch('engine') === 'qwen' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
@@ -315,20 +324,25 @@ export function FloatingGenerateBox({
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute top-0 right-[calc(100%+0.5rem)]"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsInstructMode(!isInstructMode)}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-full transition-all duration-200',
|
||||
isInstructMode
|
||||
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||
: 'bg-card border border-border hover:bg-background/50',
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsInstructMode(!isInstructMode)}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-full transition-all duration-200',
|
||||
isInstructMode
|
||||
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||
: 'bg-card border border-border hover:bg-background/50',
|
||||
)}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
Fine tune instructions
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -388,30 +402,48 @@ 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'
|
||||
: form.watch('engine') === 'chatterbox'
|
||||
? 'chatterbox'
|
||||
: `qwen:${form.watch('modelSize') || '1.7B'}`
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'luxtts') {
|
||||
form.setValue('engine', 'luxtts');
|
||||
} else if (value === 'chatterbox') {
|
||||
form.setValue('engine', 'chatterbox');
|
||||
} 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>
|
||||
<SelectItem value="chatterbox" className="text-xs text-muted-foreground">
|
||||
Chatterbox
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -76,29 +76,74 @@ 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') === 'qwen' && (
|
||||
<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'
|
||||
: form.watch('engine') === 'chatterbox'
|
||||
? 'chatterbox'
|
||||
: `qwen:${form.watch('modelSize') || '1.7B'}`
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'luxtts') {
|
||||
form.setValue('engine', 'luxtts');
|
||||
} else if (value === 'chatterbox') {
|
||||
form.setValue('engine', 'chatterbox');
|
||||
} 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>
|
||||
<SelectItem value="chatterbox">Chatterbox</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{form.watch('engine') === 'luxtts'
|
||||
? 'Fast, English-focused'
|
||||
: form.watch('engine') === 'chatterbox'
|
||||
? 'Multilingual, incl. Hebrew'
|
||||
: 'Multi-language, two sizes'}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
@@ -124,29 +169,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 +192,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" />
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { AudioWaveform, Download, FileArchive, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
AudioWaveform,
|
||||
Download,
|
||||
FileArchive,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -18,6 +26,7 @@ import {
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { HistoryResponse } from '@/lib/api/types';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import {
|
||||
useDeleteGeneration,
|
||||
@@ -33,18 +42,27 @@ import { usePlayerStore } from '@/stores/playerStore';
|
||||
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
||||
// This is the new alternate history view with fixed height rows
|
||||
|
||||
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS
|
||||
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
|
||||
export function HistoryTable() {
|
||||
const [page, _setPage] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [allHistory, setAllHistory] = useState<HistoryResponse[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
const limit = 20;
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: historyData, isLoading } = useHistory({
|
||||
const {
|
||||
data: historyData,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useHistory({
|
||||
limit,
|
||||
offset: page * limit,
|
||||
});
|
||||
@@ -60,6 +78,56 @@ export function HistoryTable() {
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
|
||||
// Update accumulated history when new data arrives
|
||||
useEffect(() => {
|
||||
if (historyData?.items) {
|
||||
setTotal(historyData.total);
|
||||
if (page === 0) {
|
||||
// Reset to first page
|
||||
setAllHistory(historyData.items);
|
||||
} else {
|
||||
// Append new items, avoiding duplicates
|
||||
setAllHistory((prev) => {
|
||||
const existingIds = new Set(prev.map((item) => item.id));
|
||||
const newItems = historyData.items.filter((item) => !existingIds.has(item.id));
|
||||
return [...prev, ...newItems];
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [historyData, page]);
|
||||
|
||||
// Reset to page 0 when deletions or imports occur
|
||||
useEffect(() => {
|
||||
if (deleteGeneration.isSuccess || importGeneration.isSuccess) {
|
||||
setPage(0);
|
||||
setAllHistory([]);
|
||||
}
|
||||
}, [deleteGeneration.isSuccess, importGeneration.isSuccess]);
|
||||
|
||||
// Intersection Observer for infinite scroll
|
||||
useEffect(() => {
|
||||
const loadMoreEl = loadMoreRef.current;
|
||||
if (!loadMoreEl) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const target = entries[0];
|
||||
if (target.isIntersecting && !isFetching && allHistory.length < total) {
|
||||
setPage((prev) => prev + 1);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollRef.current,
|
||||
rootMargin: '100px',
|
||||
threshold: 0.1,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(loadMoreEl);
|
||||
return () => observer.disconnect();
|
||||
}, [isFetching, allHistory.length, total]);
|
||||
|
||||
// Track scroll position for gradient effect
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return;
|
||||
@@ -113,24 +181,16 @@ export function HistoryTable() {
|
||||
);
|
||||
};
|
||||
|
||||
const _handleImportClick = () => {
|
||||
file_handleImportClickk.click();
|
||||
const handleDeleteClick = (generationId: string, profileName: string) => {
|
||||
setGenerationToDelete({ id: generationId, name: profileName });
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const _handleFileChange = (_e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
cons_handleFileChangeet.files?.[0];
|
||||
if (file) {
|
||||
// Validate file extension
|
||||
if (!file.name.endsWith('.voicebox.zip')) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please select a valid .voicebox.zip file',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setImportDialogOpen(true);
|
||||
const handleDeleteConfirm = () => {
|
||||
if (generationToDelete) {
|
||||
deleteGeneration.mutate(generationToDelete.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setGenerationToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -159,13 +219,16 @@ export function HistoryTable() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
if (isLoading && page === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const history = historyData?.items || [];
|
||||
const total = historyData?.total || 0;
|
||||
const _hasMore = history.length === limit && (page + 1) * limit < total;
|
||||
const history = allHistory;
|
||||
const hasMore = allHistory.length < total;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 relative">
|
||||
@@ -229,6 +292,7 @@ export function HistoryTable() {
|
||||
<Textarea
|
||||
value={gen.text}
|
||||
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -271,7 +335,7 @@ export function HistoryTable() {
|
||||
Export Package
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => deleteGeneration.mutate(gen.id)}
|
||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||
disabled={deleteGeneration.isPending}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
@@ -284,10 +348,53 @@ export function HistoryTable() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Load more trigger element */}
|
||||
{hasMore && (
|
||||
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
|
||||
{isFetching && <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End of list indicator */}
|
||||
{!hasMore && history.length > 0 && (
|
||||
<div className="text-center py-4 text-xs text-muted-foreground">
|
||||
You've reached the end
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Generation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this generation from "{generationToDelete?.name}"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setGenerationToDelete(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteGeneration.isPending}
|
||||
>
|
||||
{deleteGeneration.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
||||
|
||||
export function ModelsTab() {
|
||||
return (
|
||||
<div className="space-y-4 overflow-y-auto flex flex-col">
|
||||
<div className="h-full flex flex-col p-4">
|
||||
<ModelManagement />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { setKeepServerRunning } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
const connectionSchema = z.object({
|
||||
serverUrl: z.string().url('Please enter a valid URL'),
|
||||
@@ -26,6 +26,7 @@ const connectionSchema = z.object({
|
||||
type ConnectionFormValues = z.infer<typeof connectionSchema>;
|
||||
|
||||
export function ConnectionForm() {
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
||||
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
||||
@@ -89,7 +90,7 @@ export function ConnectionForm() {
|
||||
checked={keepServerRunningOnClose}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setKeepServerRunningOnClose(checked);
|
||||
setKeepServerRunning(checked).catch((error) => {
|
||||
platform.lifecycle.setKeepServerRunning(checked).catch((error) => {
|
||||
console.error('Failed to sync setting to Rust:', error);
|
||||
});
|
||||
toast({
|
||||
|
||||
@@ -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: (query) => (query.state.status === 'pending' ? 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,6 +1,21 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Download, Loader2, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
Download,
|
||||
ExternalLink,
|
||||
HardDrive,
|
||||
Heart,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Scale,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -13,29 +28,162 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { ActiveDownloadTask, HuggingFaceModelInfo, ModelStatus } from '@/lib/api/types';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import { ModelProgress } from './ModelProgress';
|
||||
|
||||
async function fetchHuggingFaceModelInfo(repoId: string): Promise<HuggingFaceModelInfo> {
|
||||
const response = await fetch(`https://huggingface.co/api/models/${repoId}`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch model info: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function formatDownloads(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
function formatLicense(license: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'apache-2.0': 'Apache 2.0',
|
||||
mit: 'MIT',
|
||||
'cc-by-4.0': 'CC BY 4.0',
|
||||
'cc-by-sa-4.0': 'CC BY-SA 4.0',
|
||||
'cc-by-nc-4.0': 'CC BY-NC 4.0',
|
||||
'openrail++': 'OpenRAIL++',
|
||||
openrail: 'OpenRAIL',
|
||||
};
|
||||
return map[license] || license;
|
||||
}
|
||||
|
||||
function formatPipelineTag(tag: string): string {
|
||||
return tag
|
||||
.split('-')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function 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]}`;
|
||||
}
|
||||
|
||||
export function ModelManagement() {
|
||||
const { toast } = useToast();
|
||||
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());
|
||||
|
||||
// Modal state
|
||||
const [selectedModel, setSelectedModel] = useState<ModelStatus | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const { data: modelStatus, isLoading } = useQuery({
|
||||
queryKey: ['modelStatus'],
|
||||
queryFn: () => apiClient.getModelStatus(),
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
queryFn: async () => {
|
||||
const result = await apiClient.getModelStatus();
|
||||
return result;
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
// Use progress toast hook for the downloading model
|
||||
const { data: activeTasks } = useQuery({
|
||||
queryKey: ['activeTasks'],
|
||||
queryFn: () => apiClient.getActiveTasks(),
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
const hasActive = data?.downloads.some((d) => d.status === 'downloading');
|
||||
return hasActive ? 1000 : 5000;
|
||||
},
|
||||
});
|
||||
|
||||
// HuggingFace model card query - only fetches when modal is open and model has a repo ID
|
||||
const { data: hfModelInfo, isLoading: hfLoading } = useQuery({
|
||||
queryKey: ['hfModelInfo', selectedModel?.hf_repo_id],
|
||||
queryFn: () => fetchHuggingFaceModelInfo(selectedModel!.hf_repo_id!),
|
||||
enabled: detailOpen && !!selectedModel?.hf_repo_id,
|
||||
staleTime: 1000 * 60 * 30, // Cache for 30 minutes
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
// Build a map of errored downloads for quick lookup, excluding dismissed ones
|
||||
const erroredDownloads = new Map<string, ActiveDownloadTask>();
|
||||
if (activeTasks?.downloads) {
|
||||
for (const dl of activeTasks.downloads) {
|
||||
if (dl.status === 'error' && !dismissedErrors.has(dl.model_name)) {
|
||||
const localErr = localErrors.get(dl.model_name);
|
||||
erroredDownloads.set(dl.model_name, localErr ? { ...dl, error: localErr } : dl);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
// Build progress map from active tasks for inline display
|
||||
const downloadProgressMap = useMemo(() => {
|
||||
const map = new Map<string, ActiveDownloadTask>();
|
||||
if (activeTasks?.downloads) {
|
||||
for (const dl of activeTasks.downloads) {
|
||||
if (dl.status === 'downloading') {
|
||||
map.set(dl.model_name, dl);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [activeTasks]);
|
||||
|
||||
const handleDownloadComplete = useCallback(() => {
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
|
||||
}, [queryClient]);
|
||||
|
||||
const handleDownloadError = useCallback(
|
||||
(error: string) => {
|
||||
if (downloadingModel) {
|
||||
setLocalErrors((prev) => new Map(prev).set(downloadingModel, error));
|
||||
setConsoleOpen(true);
|
||||
}
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
|
||||
},
|
||||
[queryClient, downloadingModel],
|
||||
);
|
||||
|
||||
useModelDownloadToast({
|
||||
modelName: downloadingModel || '',
|
||||
displayName: downloadingDisplayName || '',
|
||||
enabled: !!downloadingModel && !!downloadingDisplayName,
|
||||
onComplete: handleDownloadComplete,
|
||||
onError: handleDownloadError,
|
||||
});
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@@ -45,42 +193,103 @@ export function ModelManagement() {
|
||||
sizeMb?: number;
|
||||
} | null>(null);
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: (modelName: string) => {
|
||||
const handleDownload = async (modelName: string) => {
|
||||
setDismissedErrors((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(modelName);
|
||||
return next;
|
||||
});
|
||||
|
||||
const model = modelStatus?.models.find((m) => m.model_name === modelName);
|
||||
const displayName = model?.display_name || modelName;
|
||||
|
||||
try {
|
||||
await apiClient.triggerModelDownload(modelName);
|
||||
|
||||
setDownloadingModel(modelName);
|
||||
// Find display name from model status
|
||||
const model = modelStatus?.models.find((m) => m.model_name === modelName);
|
||||
setDownloadingDisplayName(model?.display_name || modelName);
|
||||
return apiClient.triggerModelDownload(modelName);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Download completed - clear state and refetch status
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
setDownloadingDisplayName(displayName);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
|
||||
} catch (error) {
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
toast({
|
||||
title: 'Download failed',
|
||||
description: error.message,
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
const prevDismissed = dismissedErrors;
|
||||
const prevLocalErrors = localErrors;
|
||||
const prevDownloadingModel = downloadingModel;
|
||||
const prevDownloadingDisplayName = downloadingDisplayName;
|
||||
|
||||
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: () => {
|
||||
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: (modelName: string) => apiClient.deleteModel(modelName),
|
||||
onSuccess: () => {
|
||||
mutationFn: async (modelName: string) => {
|
||||
const result = await apiClient.deleteModel(modelName);
|
||||
return result;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast({
|
||||
title: 'Model deleted',
|
||||
description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`,
|
||||
});
|
||||
setDeleteDialogOpen(false);
|
||||
setModelToDelete(null);
|
||||
// Refetch status to update UI
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
setDetailOpen(false);
|
||||
setSelectedModel(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' });
|
||||
await queryClient.refetchQueries({ queryKey: ['modelStatus'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
@@ -92,100 +301,438 @@ export function ModelManagement() {
|
||||
});
|
||||
|
||||
const formatSize = (sizeMb?: number): string => {
|
||||
if (!sizeMb) return 'Unknown';
|
||||
if (!sizeMb) return 'Unknown size';
|
||||
if (sizeMb < 1024) return `${sizeMb.toFixed(1)} MB`;
|
||||
return `${(sizeMb / 1024).toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
const getModelState = (model: ModelStatus) => {
|
||||
const isDownloading =
|
||||
(model.downloading || downloadingModel === model.model_name) &&
|
||||
!erroredDownloads.has(model.model_name) &&
|
||||
!dismissedErrors.has(model.model_name);
|
||||
const hasError = erroredDownloads.has(model.model_name);
|
||||
return { isDownloading, hasError };
|
||||
};
|
||||
|
||||
const openModelDetail = (model: ModelStatus) => {
|
||||
setSelectedModel(model);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
const ttsModels = modelStatus?.models.filter((m) => m.model_name.startsWith('qwen-tts')) ?? [];
|
||||
const otherTtsModels =
|
||||
modelStatus?.models.filter(
|
||||
(m) => m.model_name.startsWith('luxtts') || m.model_name.startsWith('chatterbox'),
|
||||
) ?? [];
|
||||
const whisperModels = modelStatus?.models.filter((m) => m.model_name.startsWith('whisper')) ?? [];
|
||||
|
||||
// Build sections
|
||||
const sections: { label: string; models: ModelStatus[] }[] = [
|
||||
{ label: 'Voice Generation', models: ttsModels },
|
||||
...(otherTtsModels.length > 0 ? [{ label: 'Other Voice Models', models: otherTtsModels }] : []),
|
||||
{ label: 'Transcription', models: whisperModels },
|
||||
];
|
||||
|
||||
// Get detail modal state for selected model
|
||||
const selectedState = selectedModel ? getModelState(selectedModel) : null;
|
||||
const selectedError = selectedModel ? erroredDownloads.get(selectedModel.model_name) : undefined;
|
||||
|
||||
// Keep selectedModel data fresh from query results
|
||||
const freshSelectedModel =
|
||||
selectedModel && modelStatus
|
||||
? modelStatus.models.find((m) => m.model_name === selectedModel.model_name) || selectedModel
|
||||
: selectedModel;
|
||||
|
||||
// Derive license from HF data
|
||||
const license =
|
||||
hfModelInfo?.cardData?.license ||
|
||||
hfModelInfo?.tags?.find((t) => t.startsWith('license:'))?.replace('license:', '');
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Model Management</CardTitle>
|
||||
<CardDescription>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 pb-4">
|
||||
<h1 className="text-lg font-semibold">Models</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Download and manage AI models for voice generation and transcription
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : modelStatus ? (
|
||||
<div className="space-y-4">
|
||||
{/* TTS Models */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
|
||||
Voice Generation Models
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{modelStatus.models
|
||||
.filter((m) => m.model_name.startsWith('qwen-tts'))
|
||||
.map((model) => (
|
||||
<ModelItem
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model list */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : modelStatus ? (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-6">
|
||||
{sections.map((section) => (
|
||||
<div key={section.label}>
|
||||
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
|
||||
{section.label}
|
||||
</h2>
|
||||
<div className="border rounded-lg divide-y overflow-hidden">
|
||||
{section.models.map((model) => {
|
||||
const { isDownloading, hasError } = getModelState(model);
|
||||
return (
|
||||
<button
|
||||
key={model.model_name}
|
||||
model={model}
|
||||
onDownload={() => downloadMutation.mutate(model.model_name)}
|
||||
onDelete={() => {
|
||||
type="button"
|
||||
onClick={() => openModelDetail(model)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<div className="shrink-0">
|
||||
{hasError ? (
|
||||
<CircleX className="h-4 w-4 text-destructive" />
|
||||
) : isDownloading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : model.loaded ? (
|
||||
<CircleCheck className="h-4 w-4 text-accent" />
|
||||
) : model.downloaded ? (
|
||||
<CircleCheck className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name + inline progress */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{model.display_name}</span>
|
||||
{isDownloading &&
|
||||
(() => {
|
||||
const dl = downloadProgressMap.get(model.model_name);
|
||||
const pct = dl?.progress ?? 0;
|
||||
const hasProgress = dl && dl.total && dl.total > 0;
|
||||
return (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
<Progress value={hasProgress ? pct : undefined} className="h-1" />
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{hasProgress
|
||||
? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(0)}%)`
|
||||
: dl?.filename || 'Connecting...'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Right side info */}
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{hasError && (
|
||||
<Badge variant="destructive" className="text-[10px] h-5">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
{model.loaded && (
|
||||
<Badge className="text-[10px] h-5 bg-accent/15 text-accent border-accent/30 hover:bg-accent/15">
|
||||
Loaded
|
||||
</Badge>
|
||||
)}
|
||||
{model.downloaded && !isDownloading && !hasError && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatSize(model.size_mb)}
|
||||
</span>
|
||||
)}
|
||||
{!model.downloaded && !isDownloading && !hasError && (
|
||||
<span className="text-xs text-muted-foreground/60">Not downloaded</span>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Error console */}
|
||||
{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}
|
||||
|
||||
{/* Model Detail Modal */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{freshSelectedModel && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{freshSelectedModel.display_name}</DialogTitle>
|
||||
<DialogDescription className="flex items-center gap-1.5">
|
||||
{freshSelectedModel.hf_repo_id ? (
|
||||
<a
|
||||
href={`https://huggingface.co/${freshSelectedModel.hf_repo_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{freshSelectedModel.hf_repo_id}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
freshSelectedModel.model_name
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
{/* Status badges */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{freshSelectedModel.loaded && (
|
||||
<Badge className="text-xs bg-accent/15 text-accent border-accent/30 hover:bg-accent/15">
|
||||
<CircleCheck className="h-3 w-3 mr-1" />
|
||||
Loaded
|
||||
</Badge>
|
||||
)}
|
||||
{freshSelectedModel.downloaded && !freshSelectedModel.loaded && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<CircleCheck className="h-3 w-3 mr-1" />
|
||||
Downloaded
|
||||
</Badge>
|
||||
)}
|
||||
{selectedState?.hasError && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<CircleX className="h-3 w-3 mr-1" />
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
{!freshSelectedModel.downloaded &&
|
||||
!selectedState?.isDownloading &&
|
||||
!selectedState?.hasError && (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
Not downloaded
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* HuggingFace model card info */}
|
||||
{hfLoading && freshSelectedModel.hf_repo_id && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading model info...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hfModelInfo && (
|
||||
<div className="space-y-3">
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1" title="Downloads">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{formatDownloads(hfModelInfo.downloads)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Likes">
|
||||
<Heart className="h-3.5 w-3.5" />
|
||||
{formatDownloads(hfModelInfo.likes)}
|
||||
</span>
|
||||
{license && (
|
||||
<span className="flex items-center gap-1" title="License">
|
||||
<Scale className="h-3.5 w-3.5" />
|
||||
{formatLicense(license)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pipeline tag + author */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{hfModelInfo.pipeline_tag && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{formatPipelineTag(hfModelInfo.pipeline_tag)}
|
||||
</Badge>
|
||||
)}
|
||||
{hfModelInfo.library_name && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{hfModelInfo.library_name}
|
||||
</Badge>
|
||||
)}
|
||||
{hfModelInfo.author && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
by {hfModelInfo.author}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Languages */}
|
||||
{hfModelInfo.cardData?.language && hfModelInfo.cardData.language.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{hfModelInfo.cardData.language.length > 10
|
||||
? `${hfModelInfo.cardData.language.length} languages supported`
|
||||
: `Languages: ${hfModelInfo.cardData.language.join(', ')}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disk size */}
|
||||
{freshSelectedModel.downloaded && freshSelectedModel.size_mb && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span>{formatSize(freshSelectedModel.size_mb)} on disk</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error detail */}
|
||||
{selectedError?.error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 text-xs text-destructive">
|
||||
{selectedError.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t">
|
||||
{selectedState?.hasError ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleDownload(freshSelectedModel.model_name)}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Retry Download
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleCancel(freshSelectedModel.model_name)}
|
||||
variant="ghost"
|
||||
disabled={
|
||||
cancelMutation.isPending &&
|
||||
cancelMutation.variables === freshSelectedModel.model_name
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : selectedState?.isDownloading ? (
|
||||
<>
|
||||
<div className="flex-1 space-y-2">
|
||||
{(() => {
|
||||
const dl = freshSelectedModel
|
||||
? downloadProgressMap.get(freshSelectedModel.model_name)
|
||||
: undefined;
|
||||
const pct = dl?.progress ?? 0;
|
||||
const hasProgress = dl && dl.total && dl.total > 0;
|
||||
return (
|
||||
<>
|
||||
<Progress value={hasProgress ? pct : undefined} className="h-2" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{hasProgress
|
||||
? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(1)}%)`
|
||||
: dl?.filename || 'Connecting to HuggingFace...'}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleCancel(freshSelectedModel.model_name)}
|
||||
variant="ghost"
|
||||
disabled={
|
||||
cancelMutation.isPending &&
|
||||
cancelMutation.variables === freshSelectedModel.model_name
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : freshSelectedModel.downloaded ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setModelToDelete({
|
||||
name: model.model_name,
|
||||
displayName: model.display_name,
|
||||
sizeMb: model.size_mb,
|
||||
name: freshSelectedModel.model_name,
|
||||
displayName: freshSelectedModel.display_name,
|
||||
sizeMb: freshSelectedModel.size_mb,
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
isDownloading={downloadingModel === model.model_name}
|
||||
formatSize={formatSize}
|
||||
/>
|
||||
))}
|
||||
variant="outline"
|
||||
disabled={freshSelectedModel.loaded}
|
||||
title={
|
||||
freshSelectedModel.loaded ? 'Unload model before deleting' : 'Delete model'
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{freshSelectedModel.loaded ? 'Unload to Delete' : 'Delete Model'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleDownload(freshSelectedModel.model_name)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Whisper Models */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
|
||||
Transcription Models
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{modelStatus.models
|
||||
.filter((m) => m.model_name.startsWith('whisper'))
|
||||
.map((model) => (
|
||||
<ModelItem
|
||||
key={model.model_name}
|
||||
model={model}
|
||||
onDownload={() => downloadMutation.mutate(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>
|
||||
|
||||
{/* Progress indicators */}
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
|
||||
Download Progress
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{modelStatus.models.map((model) => (
|
||||
<ModelProgress
|
||||
key={model.model_name}
|
||||
modelName={model.model_name}
|
||||
displayName={model.display_name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
@@ -226,79 +773,6 @@ export function ModelManagement() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelItemProps {
|
||||
model: {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
downloaded: boolean;
|
||||
size_mb?: number;
|
||||
loaded: boolean;
|
||||
};
|
||||
onDownload: () => void;
|
||||
onDelete: () => void;
|
||||
isDownloading: boolean;
|
||||
formatSize: (sizeMb?: number) => string;
|
||||
}
|
||||
|
||||
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{model.display_name}</span>
|
||||
{model.loaded && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Loaded
|
||||
</Badge>
|
||||
)}
|
||||
{model.downloaded && !model.loaded && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Downloaded
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{model.downloaded && model.size_mb && (
|
||||
<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 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
variant="outline"
|
||||
disabled={model.loaded}
|
||||
title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button size="sm" onClick={onDownload} disabled={isDownloading} variant="outline">
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,23 @@ import { useServerStore } from '@/stores/serverStore';
|
||||
interface ModelProgressProps {
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
/** Only connect to SSE when actively downloading - prevents connection exhaustion */
|
||||
isDownloading?: boolean;
|
||||
}
|
||||
|
||||
export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
export function ModelProgress({ modelName, displayName, isDownloading = false }: ModelProgressProps) {
|
||||
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverUrl) return;
|
||||
// IMPORTANT: Only connect to SSE when this specific model is downloading
|
||||
// Opening SSE connections for all models exhausts HTTP/1.1 connection limits (6 per origin)
|
||||
// which causes other fetches (like the download trigger) to be queued/blocked
|
||||
if (!serverUrl || !isDownloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[ModelProgress] Connecting SSE for ${modelName}`);
|
||||
|
||||
// Subscribe to progress updates via Server-Sent Events
|
||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||
@@ -27,6 +36,7 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
|
||||
// Close connection if complete or error
|
||||
if (data.status === 'complete' || data.status === 'error') {
|
||||
console.log(`[ModelProgress] Download ${data.status} for ${modelName}, closing SSE`);
|
||||
eventSource.close();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -35,14 +45,15 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
console.error(`[ModelProgress] SSE error for ${modelName}:`, error);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
console.log(`[ModelProgress] Cleanup - closing SSE for ${modelName}`);
|
||||
eventSource.close();
|
||||
};
|
||||
}, [serverUrl, modelName]);
|
||||
}, [serverUrl, modelName, isDownloading]);
|
||||
|
||||
// Don't render if no progress or if complete/error and some time has passed
|
||||
if (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { AlertCircle, Download, RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -6,16 +5,19 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function UpdateStatus() {
|
||||
const platform = usePlatform();
|
||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
getVersion()
|
||||
platform.metadata
|
||||
.getVersion()
|
||||
.then(setCurrentVersion)
|
||||
.catch(() => setCurrentVersion('0.1.0'));
|
||||
}, []);
|
||||
.catch(() => setCurrentVersion('Unknown'));
|
||||
}, [platform]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
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 { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function ServerTab() {
|
||||
const platform = usePlatform();
|
||||
return (
|
||||
<div className="space-y-4 overflow-y-auto flex flex-col">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ConnectionForm />
|
||||
<ServerStatus />
|
||||
</div>
|
||||
{isTauri() && <UpdateStatus />}
|
||||
{platform.metadata.isTauri && <GpuAcceleration />}
|
||||
{platform.metadata.isTauri && <UpdateStatus />}
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
Created by{' '}
|
||||
<a
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { StoryContent } from './StoryContent';
|
||||
import { StoryList } from './StoryList';
|
||||
|
||||
export function StoriesTab() {
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||
{/* Main content area */}
|
||||
@@ -18,7 +21,7 @@ export function StoriesTab() {
|
||||
</div>
|
||||
|
||||
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
|
||||
<FloatingGenerateBox showVoiceSelector />
|
||||
<FloatingGenerateBox showVoiceSelector isPlayerOpen={!!audioUrl} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Download, Edit, Mic, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
interface ProfileCardProps {
|
||||
@@ -35,9 +35,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
|
||||
const isSelected = selectedProfileId === profile.id;
|
||||
|
||||
const avatarUrl = profile.avatar_path
|
||||
? `${serverUrl}/profiles/${profile.id}/avatar`
|
||||
: null;
|
||||
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
|
||||
|
||||
const handleSelect = () => {
|
||||
setSelectedProfileId(isSelected ? null : profile.id);
|
||||
@@ -81,7 +79,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
alt={`${profile.name} avatar`}
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-all duration-200',
|
||||
!isSelected && 'grayscale'
|
||||
!isSelected && 'grayscale',
|
||||
)}
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
|
||||
@@ -43,10 +43,10 @@ import {
|
||||
} from '@/lib/hooks/useProfiles';
|
||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
||||
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
|
||||
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';
|
||||
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||
@@ -102,6 +102,7 @@ function base64ToFile(base64: string, fileName: string, fileType: string): File
|
||||
}
|
||||
|
||||
export function ProfileForm() {
|
||||
const platform = usePlatform();
|
||||
const open = useUIStore((state) => state.profileDialogOpen);
|
||||
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const editingProfileId = useUIStore((state) => state.editingProfileId);
|
||||
@@ -427,7 +428,8 @@ export function ProfileForm() {
|
||||
} catch (avatarError) {
|
||||
toast({
|
||||
title: 'Avatar upload failed',
|
||||
description: avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
description:
|
||||
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -503,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,
|
||||
});
|
||||
|
||||
@@ -520,7 +535,8 @@ export function ProfileForm() {
|
||||
} catch (avatarError) {
|
||||
toast({
|
||||
title: 'Avatar upload failed',
|
||||
description: avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
description:
|
||||
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -662,7 +678,7 @@ export function ProfileForm() {
|
||||
}}
|
||||
>
|
||||
<TabsList
|
||||
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
@@ -672,7 +688,7 @@ export function ProfileForm() {
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
</TabsTrigger>
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
@@ -724,7 +740,7 @@ export function ProfileForm() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsContent value="system" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { Plus, Trash2, Play, Edit, Check, X, Volume2, Pause } from 'lucide-react';
|
||||
import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CircleButton } from '@/components/ui/circle-button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles';
|
||||
@@ -140,10 +148,19 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [editingSampleId, setEditingSampleId] = useState<string | null>(null);
|
||||
const [editedText, setEditedText] = useState<string>('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [sampleToDelete, setSampleToDelete] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = (sampleId: string) => {
|
||||
if (confirm('Are you sure you want to delete this sample?')) {
|
||||
deleteSample.mutate(sampleId);
|
||||
const handleDeleteClick = (sampleId: string) => {
|
||||
setSampleToDelete(sampleId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (sampleToDelete) {
|
||||
deleteSample.mutate(sampleToDelete);
|
||||
setDeleteDialogOpen(false);
|
||||
setSampleToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,7 +211,9 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
||||
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No samples yet</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">Add your first audio sample to get started</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Add your first audio sample to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -206,7 +225,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
key={sample.id}
|
||||
className={cn(
|
||||
'group relative rounded-lg border bg-card transition-all duration-200',
|
||||
isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30'
|
||||
isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30',
|
||||
)}
|
||||
>
|
||||
{isEditing ? (
|
||||
@@ -266,7 +285,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
<CircleButton
|
||||
icon={Trash2}
|
||||
title="Delete sample"
|
||||
onClick={() => handleDelete(sample.id)}
|
||||
onClick={() => handleDeleteClick(sample.id)}
|
||||
disabled={deleteSample.isPending}
|
||||
/>
|
||||
</div>
|
||||
@@ -287,12 +306,52 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="button" variant="outline" className="w-full" onClick={() => setUploadOpen(true)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Sample
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center px-2">
|
||||
Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple
|
||||
samples. In a future update samples might be interchangeable and tagged for varying styles
|
||||
of the same voice.
|
||||
</p>
|
||||
|
||||
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Sample</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this audio sample? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setSampleToDelete(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteSample.isPending}
|
||||
>
|
||||
{deleteSample.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||
@@ -49,6 +49,7 @@ interface SampleUploadProps {
|
||||
}
|
||||
|
||||
export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) {
|
||||
const platform = usePlatform();
|
||||
const addSample = useAddSample();
|
||||
const transcribe = useTranscription();
|
||||
const { data: profile } = useProfile(profileId);
|
||||
@@ -232,7 +233,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record' | 'system')}>
|
||||
<TabsList
|
||||
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
@@ -242,7 +243,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
</TabsTrigger>
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
@@ -289,7 +290,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsContent value="system" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,10 +6,11 @@ export interface CircleButtonProps extends React.ButtonHTMLAttributes<HTMLButton
|
||||
}
|
||||
|
||||
const CircleButton = React.forwardRef<HTMLButtonElement, CircleButtonProps>(
|
||||
({ className, icon: Icon, ...props }, ref) => {
|
||||
({ className, icon: Icon, type = 'button', ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
'hover:bg-muted transition-colors',
|
||||
|
||||
+32
-157
@@ -1,172 +1,47 @@
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import { check, type Update } from '@tauri-apps/plugin-updater';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import type { UpdateStatus } from '@/platform/types';
|
||||
|
||||
export interface UpdateStatus {
|
||||
checking: boolean;
|
||||
available: boolean;
|
||||
version?: string;
|
||||
downloading: boolean;
|
||||
installing: boolean;
|
||||
readyToInstall: boolean;
|
||||
error?: string;
|
||||
downloadProgress?: number; // 0-100 percentage
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
}
|
||||
|
||||
// Check if we're on Windows (NSIS installer handles restart automatically)
|
||||
const isWindows = () => {
|
||||
return navigator.userAgent.includes('Windows');
|
||||
};
|
||||
|
||||
const isTauri = () => {
|
||||
return '__TAURI_INTERNALS__' in window;
|
||||
};
|
||||
// Re-export UpdateStatus for backwards compatibility
|
||||
export type { UpdateStatus };
|
||||
|
||||
export function useAutoUpdater(checkOnMount = false) {
|
||||
const [status, setStatus] = useState<UpdateStatus>({
|
||||
checking: false,
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
const platform = usePlatform();
|
||||
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||
const hasCheckedRef = useRef(false);
|
||||
|
||||
const [update, setUpdate] = useState<Update | null>(null);
|
||||
// Subscribe to updater status changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = platform.updater.subscribe((newStatus) => {
|
||||
setStatus(newStatus);
|
||||
});
|
||||
return unsubscribe;
|
||||
// Empty dependency array - platform is stable from context
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.subscribe]);
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
await platform.updater.checkForUpdates();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.checkForUpdates]);
|
||||
|
||||
try {
|
||||
setStatus((prev) => ({ ...prev, checking: true, error: undefined }));
|
||||
const downloadAndInstall = useCallback(async () => {
|
||||
await platform.updater.downloadAndInstall();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.downloadAndInstall]);
|
||||
|
||||
const foundUpdate = await check();
|
||||
|
||||
if (foundUpdate?.available) {
|
||||
setUpdate(foundUpdate);
|
||||
setStatus({
|
||||
checking: false,
|
||||
available: true,
|
||||
version: foundUpdate.version,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
} else {
|
||||
setStatus({
|
||||
checking: false,
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
checking: false,
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Download the update (but don't install yet)
|
||||
const downloadAndInstall = async () => {
|
||||
if (!update || !isTauri()) return;
|
||||
|
||||
try {
|
||||
setStatus((prev) => ({ ...prev, downloading: true, error: undefined }));
|
||||
|
||||
let downloadedBytes = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
// Just download the update
|
||||
await update.download((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
totalBytes = event.data.contentLength || 0;
|
||||
downloadedBytes = 0;
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
downloading: true,
|
||||
totalBytes,
|
||||
downloadedBytes: 0,
|
||||
downloadProgress: 0,
|
||||
}));
|
||||
break;
|
||||
case 'Progress': {
|
||||
downloadedBytes += event.data.chunkLength;
|
||||
const progress =
|
||||
totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : undefined;
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
downloadedBytes,
|
||||
downloadProgress: progress,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'Finished':
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
downloading: false,
|
||||
readyToInstall: true,
|
||||
downloadProgress: 100,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
downloadProgress: undefined,
|
||||
downloadedBytes: undefined,
|
||||
totalBytes: undefined,
|
||||
error: error instanceof Error ? error.message : 'Failed to download update',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Install the downloaded update and restart the app
|
||||
const restartAndInstall = async () => {
|
||||
if (!update || !isTauri()) return;
|
||||
|
||||
try {
|
||||
setStatus((prev) => ({ ...prev, installing: true, error: undefined }));
|
||||
|
||||
// Install the update
|
||||
await update.install();
|
||||
|
||||
// On Windows with NSIS, the installer handles the restart automatically.
|
||||
// The process will be killed by the NSIS installer, so we won't reach here.
|
||||
// On macOS/Linux, we need to manually relaunch.
|
||||
if (!isWindows()) {
|
||||
await relaunch();
|
||||
}
|
||||
// If we're on Windows and somehow still running, the NSIS installer
|
||||
// should have already handled everything. Just wait for the process to end.
|
||||
} catch (error) {
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
installing: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to install update',
|
||||
}));
|
||||
}
|
||||
};
|
||||
const restartAndInstall = useCallback(async () => {
|
||||
await platform.updater.restartAndInstall();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.restartAndInstall]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkOnMount && isTauri()) {
|
||||
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||
hasCheckedRef.current = true;
|
||||
checkForUpdates();
|
||||
}
|
||||
}, [checkOnMount, checkForUpdates]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||
|
||||
return {
|
||||
status,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Download, RefreshCw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ToastAction } from '@/components/ui/toast';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import type { UpdateStatus } from '@/platform/types';
|
||||
|
||||
// Re-export UpdateStatus for backwards compatibility
|
||||
export type { UpdateStatus };
|
||||
|
||||
interface UseAutoUpdaterOptions {
|
||||
checkOnMount?: boolean;
|
||||
showToast?: boolean;
|
||||
}
|
||||
|
||||
export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) {
|
||||
// Support both old boolean API and new options object
|
||||
const { checkOnMount, showToast } =
|
||||
typeof options === 'boolean'
|
||||
? { checkOnMount: options, showToast: false }
|
||||
: { checkOnMount: options.checkOnMount ?? false, showToast: options.showToast ?? false };
|
||||
|
||||
const platform = usePlatform();
|
||||
const { toast } = useToast();
|
||||
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||
const hasCheckedRef = useRef(false);
|
||||
const toastIdRef = useRef<string | null>(null);
|
||||
const toastUpdateRef = useRef<
|
||||
| ((props: {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
duration?: number;
|
||||
variant?: 'default' | 'destructive';
|
||||
open?: boolean;
|
||||
action?: React.ReactElement<typeof ToastAction>;
|
||||
}) => void)
|
||||
| null
|
||||
>(null);
|
||||
|
||||
// Subscribe to updater status changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = platform.updater.subscribe((newStatus) => {
|
||||
setStatus(newStatus);
|
||||
});
|
||||
return unsubscribe;
|
||||
// Empty dependency array - platform is stable from context
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.subscribe]);
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
await platform.updater.checkForUpdates();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.checkForUpdates]);
|
||||
|
||||
const downloadAndInstall = useCallback(async () => {
|
||||
await platform.updater.downloadAndInstall();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.downloadAndInstall]);
|
||||
|
||||
const restartAndInstall = useCallback(async () => {
|
||||
await platform.updater.restartAndInstall();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.restartAndInstall]);
|
||||
|
||||
// Check for updates on mount
|
||||
useEffect(() => {
|
||||
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||
hasCheckedRef.current = true;
|
||||
checkForUpdates().catch((error) => {
|
||||
console.error('Auto update check failed:', error);
|
||||
});
|
||||
}
|
||||
// Empty dependency array - only run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||
|
||||
// Show toast when update is available
|
||||
useEffect(() => {
|
||||
if (
|
||||
!showToast ||
|
||||
!status.available ||
|
||||
status.downloading ||
|
||||
status.readyToInstall ||
|
||||
toastIdRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleUpdateNow = async () => {
|
||||
await downloadAndInstall();
|
||||
};
|
||||
|
||||
const toastResult = toast({
|
||||
title: 'Update Available',
|
||||
description: `Version ${status.version} is ready to download.`,
|
||||
duration: Infinity,
|
||||
action: (
|
||||
<ToastAction altText="Update now" onClick={handleUpdateNow}>
|
||||
Update Now
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
|
||||
toastIdRef.current = toastResult.id;
|
||||
// Type assertion needed because update function has broader type than our ref
|
||||
toastUpdateRef.current = toastResult.update as typeof toastUpdateRef.current;
|
||||
}, [
|
||||
showToast,
|
||||
status.available,
|
||||
status.downloading,
|
||||
status.readyToInstall,
|
||||
status.version,
|
||||
downloadAndInstall,
|
||||
toast,
|
||||
]);
|
||||
|
||||
// Update toast when downloading
|
||||
useEffect(() => {
|
||||
if (!showToast || !status.downloading || !toastIdRef.current || !toastUpdateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progressPercent = status.downloadProgress || 0;
|
||||
const progressText =
|
||||
status.downloadedBytes !== undefined &&
|
||||
status.totalBytes !== undefined &&
|
||||
status.totalBytes > 0
|
||||
? `${(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB / ${(status.totalBytes / 1024 / 1024).toFixed(1)} MB`
|
||||
: '';
|
||||
|
||||
toastUpdateRef.current({
|
||||
title: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 animate-pulse" />
|
||||
<span>Downloading Update</span>
|
||||
</div>
|
||||
),
|
||||
description: (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Version {status.version}</div>
|
||||
{progressPercent > 0 && (
|
||||
<>
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
{progressText && <div className="text-xs text-muted-foreground">{progressText}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
duration: Infinity,
|
||||
});
|
||||
}, [
|
||||
showToast,
|
||||
status.downloading,
|
||||
status.downloadProgress,
|
||||
status.downloadedBytes,
|
||||
status.totalBytes,
|
||||
status.version,
|
||||
]);
|
||||
|
||||
// Update toast when ready to install
|
||||
useEffect(() => {
|
||||
if (!showToast || !status.readyToInstall || !toastIdRef.current || !toastUpdateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleRestartNow = async () => {
|
||||
await restartAndInstall();
|
||||
};
|
||||
|
||||
toastUpdateRef.current({
|
||||
title: 'Update Ready',
|
||||
description: `Version ${status.version} has been downloaded and is ready to install.`,
|
||||
duration: Infinity,
|
||||
action: (
|
||||
<ToastAction altText="Restart now" onClick={handleRestartNow}>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Restart Now
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
}, [showToast, status.readyToInstall, status.version, restartAndInstall]);
|
||||
|
||||
// Handle errors in toast
|
||||
useEffect(() => {
|
||||
if (!showToast || !status.error || !toastIdRef.current || !toastUpdateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
toastUpdateRef.current({
|
||||
title: 'Update Failed',
|
||||
description: status.error,
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
toastIdRef.current = null;
|
||||
toastUpdateRef.current = null;
|
||||
}, 5000);
|
||||
}, [showToast, status.error]);
|
||||
|
||||
return {
|
||||
status,
|
||||
checkForUpdates,
|
||||
downloadAndInstall,
|
||||
restartAndInstall,
|
||||
};
|
||||
}
|
||||
+77
-31
@@ -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,10 +317,18 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
|
||||
return this.request<{ message: string }>('/models/download', {
|
||||
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),
|
||||
});
|
||||
console.log('[API] triggerModelDownload response:', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteModel(modelName: string): Promise<{ message: string }> {
|
||||
@@ -322,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<{
|
||||
@@ -340,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;
|
||||
@@ -385,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 }),
|
||||
@@ -399,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');
|
||||
@@ -465,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),
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ModelStatus = {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
downloaded: boolean;
|
||||
downloading?: boolean; // True if download is in progress
|
||||
size_mb?: number | null;
|
||||
loaded?: boolean;
|
||||
};
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface GenerationRequest {
|
||||
language: LanguageCode;
|
||||
seed?: number;
|
||||
model_size?: '1.7B' | '0.6B';
|
||||
engine?: 'qwen' | 'luxtts' | 'chatterbox';
|
||||
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 {
|
||||
@@ -95,11 +119,29 @@ export interface ModelProgress {
|
||||
export interface ModelStatus {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
hf_repo_id?: string; // HuggingFace repository ID
|
||||
downloaded: boolean;
|
||||
downloading: boolean; // True if download is in progress
|
||||
size_mb?: number;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
export interface HuggingFaceModelInfo {
|
||||
id: string;
|
||||
author: string;
|
||||
lastModified: string;
|
||||
pipeline_tag?: string;
|
||||
library_name?: string;
|
||||
downloads: number;
|
||||
likes: number;
|
||||
tags: string[];
|
||||
cardData?: {
|
||||
license?: string;
|
||||
language?: string[];
|
||||
pipeline_tag?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ModelStatusListResponse {
|
||||
models: ModelStatus[];
|
||||
}
|
||||
@@ -112,6 +154,11 @@ export interface ActiveDownloadTask {
|
||||
model_name: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
error?: string;
|
||||
progress?: number; // 0-100 percentage
|
||||
current?: number; // bytes downloaded
|
||||
total?: number; // total bytes
|
||||
filename?: string; // current file being downloaded
|
||||
}
|
||||
|
||||
export interface ActiveGenerationTask {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Supported languages for Qwen3-TTS
|
||||
* Based on: https://github.com/QwenLM/Qwen3-TTS
|
||||
* Supported languages for voice generation.
|
||||
* Most languages use Qwen3-TTS; Hebrew uses Chatterbox TTS.
|
||||
*/
|
||||
|
||||
export const SUPPORTED_LANGUAGES = {
|
||||
@@ -14,6 +14,7 @@ export const SUPPORTED_LANGUAGES = {
|
||||
pt: 'Portuguese',
|
||||
es: 'Spanish',
|
||||
it: 'Italian',
|
||||
he: 'Hebrew',
|
||||
} as const;
|
||||
|
||||
export type LanguageCode = keyof typeof SUPPORTED_LANGUAGES;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { convertToWav } from '@/lib/utils/audio';
|
||||
|
||||
interface UseAudioRecordingOptions {
|
||||
@@ -11,6 +11,7 @@ export function useAudioRecording({
|
||||
maxDurationSeconds = 29,
|
||||
onRecordingComplete,
|
||||
}: UseAudioRecordingOptions = {}) {
|
||||
const platform = usePlatform();
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -19,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
|
||||
@@ -40,15 +43,14 @@ export function useAudioRecording({
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
const isTauriEnv = isTauri();
|
||||
console.error('MediaDevices check:', {
|
||||
hasNavigator: typeof navigator !== 'undefined',
|
||||
hasMediaDevices: !!navigator?.mediaDevices,
|
||||
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
|
||||
isTauri: isTauriEnv,
|
||||
isTauri: platform.metadata.isTauri,
|
||||
});
|
||||
|
||||
const errorMsg = isTauriEnv
|
||||
const errorMsg = platform.metadata.isTauri
|
||||
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
|
||||
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
|
||||
setError(errorMsg);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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', 'chatterbox']).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,21 @@ 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'
|
||||
: engine === 'chatterbox'
|
||||
? 'chatterbox-tts'
|
||||
: `qwen-tts-${data.modelSize}`;
|
||||
const displayName =
|
||||
engine === 'luxtts'
|
||||
? 'LuxTTS'
|
||||
: engine === 'chatterbox'
|
||||
? 'Chatterbox TTS'
|
||||
: data.modelSize === '1.7B'
|
||||
? 'Qwen TTS 1.7B'
|
||||
: 'Qwen TTS 0.6B';
|
||||
|
||||
try {
|
||||
const modelStatus = await apiClient.getModelStatus();
|
||||
@@ -82,13 +97,15 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||
console.error('Failed to check model status:', error);
|
||||
}
|
||||
|
||||
const isQwen = engine === 'qwen';
|
||||
const result = await generation.mutateAsync({
|
||||
profile_id: selectedProfileId,
|
||||
text: data.text,
|
||||
language: data.language,
|
||||
seed: data.seed,
|
||||
model_size: data.modelSize,
|
||||
instruct: data.instruct || undefined,
|
||||
model_size: isQwen ? data.modelSize : undefined,
|
||||
engine,
|
||||
instruct: isQwen ? data.instruct || undefined : undefined,
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -99,7 +116,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({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { HistoryQuery } from '@/lib/api/types';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function useHistory(query?: HistoryQuery) {
|
||||
return useQuery({
|
||||
@@ -30,116 +30,52 @@ export function useDeleteGeneration() {
|
||||
}
|
||||
|
||||
export function useExportGeneration() {
|
||||
const platform = usePlatform();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||
const blob = await apiClient.exportGeneration(generationId);
|
||||
|
||||
|
||||
// Create safe filename from text
|
||||
const safeText = text.substring(0, 30).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const safeText = text
|
||||
.substring(0, 30)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `generation-${safeText}.voicebox.zip`;
|
||||
|
||||
if (isTauri()) {
|
||||
// Use Tauri's native save dialog
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const filePath = await save({
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{
|
||||
name: 'Voicebox Generation',
|
||||
extensions: ['voicebox.zip', 'zip'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
// Write file using Tauri's filesystem API
|
||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
await writeBinaryFile(filePath, 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);
|
||||
}
|
||||
} else {
|
||||
// Browser: trigger download
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
name: 'Voicebox Generation',
|
||||
extensions: ['zip'],
|
||||
},
|
||||
]);
|
||||
|
||||
return blob;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useExportGenerationAudio() {
|
||||
const platform = usePlatform();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||
const blob = await apiClient.exportGenerationAudio(generationId);
|
||||
|
||||
|
||||
// Create safe filename from text
|
||||
const safeText = text.substring(0, 30).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const safeText = text
|
||||
.substring(0, 30)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `${safeText}.wav`;
|
||||
|
||||
if (isTauri()) {
|
||||
// Use Tauri's native save dialog
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const filePath = await save({
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{
|
||||
name: 'Audio File',
|
||||
extensions: ['wav'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
// Write file using Tauri's filesystem API
|
||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
await writeBinaryFile(filePath, 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);
|
||||
}
|
||||
} else {
|
||||
// Browser: trigger download
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
name: 'Audio File',
|
||||
extensions: ['wav'],
|
||||
},
|
||||
]);
|
||||
|
||||
return blob;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import type { ModelProgress } from '@/lib/api/types';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
interface UseModelDownloadToastOptions {
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
enabled?: boolean;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,47 +21,64 @@ export function useModelDownloadToast({
|
||||
modelName,
|
||||
displayName,
|
||||
enabled = false,
|
||||
onComplete,
|
||||
onError,
|
||||
}: UseModelDownloadToastOptions) {
|
||||
const { toast } = useToast();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const toastIdRef = useRef<string | null>(null);
|
||||
const toastUpdateRef = useRef<
|
||||
((props: {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
duration?: number;
|
||||
variant?: 'default' | 'destructive';
|
||||
open?: boolean;
|
||||
}) => void) | null
|
||||
>(null);
|
||||
// biome-ignore lint: Using any for toast update ref to handle complex toast types
|
||||
const toastUpdateRef = useRef<any>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
const formatBytes = useCallback((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 / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[useModelDownloadToast] useEffect triggered', {
|
||||
enabled,
|
||||
serverUrl,
|
||||
modelName,
|
||||
displayName,
|
||||
});
|
||||
|
||||
if (!enabled || !serverUrl || !modelName) {
|
||||
console.log('[useModelDownloadToast] Not enabled, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[useModelDownloadToast] Creating toast and EventSource for:', modelName);
|
||||
|
||||
// Create initial toast
|
||||
const toastResult = toast({
|
||||
title: displayName,
|
||||
description: 'Starting download...',
|
||||
description: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Connecting to download...</span>
|
||||
</div>
|
||||
),
|
||||
duration: Infinity, // Don't auto-dismiss, we'll handle it manually
|
||||
});
|
||||
toastIdRef.current = toastResult.id;
|
||||
toastUpdateRef.current = toastResult.update;
|
||||
|
||||
// Subscribe to progress updates via Server-Sent Events
|
||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||
const eventSourceUrl = `${serverUrl}/models/progress/${modelName}`;
|
||||
console.log('[useModelDownloadToast] Creating EventSource to:', eventSourceUrl);
|
||||
const eventSource = new EventSource(eventSourceUrl);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('[useModelDownloadToast] EventSource connection opened for:', modelName);
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
console.log('[useModelDownloadToast] Received SSE message:', event.data);
|
||||
try {
|
||||
const progress = JSON.parse(event.data) as ModelProgress;
|
||||
|
||||
@@ -82,11 +101,11 @@ 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" />;
|
||||
statusText = progress.filename ? `Downloading ${progress.filename}...` : 'Downloading...';
|
||||
statusText = progress.filename || 'Downloading...';
|
||||
break;
|
||||
case 'extracting':
|
||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||
@@ -112,26 +131,44 @@ 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
|
||||
if (progress.status === 'complete' || progress.status === 'error') {
|
||||
// Also treat progress >= 100% as complete
|
||||
const isComplete = progress.status === 'complete' || progress.progress >= 100;
|
||||
const isError = progress.status === 'error';
|
||||
|
||||
if (isComplete || isError) {
|
||||
console.log('[useModelDownloadToast] Download finished:', {
|
||||
isComplete,
|
||||
isError,
|
||||
progress: progress.progress,
|
||||
});
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
// Auto-dismiss on completion after delay
|
||||
if (progress.status === 'complete') {
|
||||
setTimeout(() => {
|
||||
if (toastIdRef.current && toastUpdateRef.current) {
|
||||
toastUpdateRef.current({
|
||||
open: false,
|
||||
});
|
||||
toastIdRef.current = null;
|
||||
toastUpdateRef.current = null;
|
||||
}
|
||||
}, 5000);
|
||||
// Update toast to show completion state before callbacks
|
||||
if (isComplete && toastUpdateRef.current) {
|
||||
toastUpdateRef.current({
|
||||
title: (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>{displayName}</span>
|
||||
</div>
|
||||
),
|
||||
description: 'Download complete',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
// Call callbacks
|
||||
if (isComplete && onComplete) {
|
||||
console.log('[useModelDownloadToast] Download complete, calling onComplete callback');
|
||||
onComplete();
|
||||
} else if (isError && onError) {
|
||||
console.log('[useModelDownloadToast] Download error, calling onError callback');
|
||||
onError(progress.error || 'Unknown error');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +178,8 @@ export function useModelDownloadToast({
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
console.error('[useModelDownloadToast] SSE error for:', modelName, error);
|
||||
console.log('[useModelDownloadToast] EventSource readyState:', eventSource.readyState);
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
@@ -162,15 +200,16 @@ export function useModelDownloadToast({
|
||||
|
||||
// Cleanup on unmount or when disabled
|
||||
return () => {
|
||||
console.log('[useModelDownloadToast] Cleanup - closing EventSource for:', modelName);
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
// Note: We don't dismiss the toast here as it might still be showing completion state
|
||||
};
|
||||
}, [enabled, serverUrl, modelName, displayName, toast]);
|
||||
}, [enabled, serverUrl, modelName, displayName, toast, formatBytes, onComplete, onError]);
|
||||
|
||||
return {
|
||||
isTracking: enabled && eventSourceRef.current !== null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { VoiceProfileCreate } from '@/lib/api/types';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function useProfiles() {
|
||||
return useQuery({
|
||||
@@ -117,59 +117,24 @@ export function useUpdateSample() {
|
||||
}
|
||||
|
||||
export function useExportProfile() {
|
||||
const platform = usePlatform();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (profileId: string) => {
|
||||
const blob = await apiClient.exportProfile(profileId);
|
||||
|
||||
|
||||
// Get profile name for filename
|
||||
const profile = await apiClient.getProfile(profileId);
|
||||
const safeName = profile.name.replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const filename = `profile-${safeName}.voicebox.zip`;
|
||||
|
||||
if (isTauri()) {
|
||||
// Use Tauri's native save dialog
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const filePath = await save({
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{
|
||||
name: 'Voicebox Profile',
|
||||
extensions: ['voicebox.zip', 'zip'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
// Write file using Tauri's filesystem API
|
||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
await writeBinaryFile(filePath, 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);
|
||||
}
|
||||
} else {
|
||||
// Browser: trigger download
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
name: 'Voicebox Profile',
|
||||
extensions: ['zip'],
|
||||
},
|
||||
]);
|
||||
|
||||
return blob;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function useStories() {
|
||||
return useQuery({
|
||||
@@ -158,6 +158,8 @@ export function useDuplicateStoryItem() {
|
||||
}
|
||||
|
||||
export function useExportStoryAudio() {
|
||||
const platform = usePlatform();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ storyId, storyName }: { storyId: string; storyName: string }) => {
|
||||
const blob = await apiClient.exportStoryAudio(storyId);
|
||||
@@ -166,49 +168,12 @@ export function useExportStoryAudio() {
|
||||
const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const filename = `${safeName || 'story'}.wav`;
|
||||
|
||||
if (isTauri()) {
|
||||
// Use Tauri's native save dialog
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const filePath = await save({
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{
|
||||
name: 'Audio File',
|
||||
extensions: ['wav'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
// Write file using Tauri's filesystem API
|
||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
await writeBinaryFile(filePath, 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);
|
||||
}
|
||||
} else {
|
||||
// Browser: trigger download
|
||||
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);
|
||||
}
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
name: 'Audio File',
|
||||
extensions: ['wav'],
|
||||
},
|
||||
]);
|
||||
|
||||
return blob;
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
interface UseSystemAudioCaptureOptions {
|
||||
maxDurationSeconds?: number;
|
||||
@@ -15,6 +14,7 @@ export function useSystemAudioCapture({
|
||||
maxDurationSeconds = 29,
|
||||
onRecordingComplete,
|
||||
}: UseSystemAudioCaptureOptions = {}) {
|
||||
const platform = usePlatform();
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -26,22 +26,12 @@ export function useSystemAudioCapture({
|
||||
|
||||
// Check if system audio capture is supported
|
||||
useEffect(() => {
|
||||
if (!isTauri()) {
|
||||
setIsSupported(false);
|
||||
return;
|
||||
}
|
||||
|
||||
invoke<boolean>('is_system_audio_supported')
|
||||
.then((supported) => {
|
||||
setIsSupported(supported);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsSupported(false);
|
||||
});
|
||||
}, []);
|
||||
const supported = platform.audio.isSystemAudioSupported();
|
||||
setIsSupported(supported);
|
||||
}, [platform]);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!isTauri()) {
|
||||
if (!platform.metadata.isTauri) {
|
||||
const errorMsg = 'System audio capture is only available in the desktop app.';
|
||||
setError(errorMsg);
|
||||
return;
|
||||
@@ -58,9 +48,7 @@ export function useSystemAudioCapture({
|
||||
setDuration(0);
|
||||
|
||||
// Start native capture
|
||||
await invoke('start_system_audio_capture', {
|
||||
maxDurationSecs: maxDurationSeconds,
|
||||
});
|
||||
await platform.audio.startSystemAudioCapture(maxDurationSeconds);
|
||||
|
||||
setIsRecording(true);
|
||||
isRecordingRef.current = true;
|
||||
@@ -86,10 +74,10 @@ export function useSystemAudioCapture({
|
||||
setError(errorMessage);
|
||||
setIsRecording(false);
|
||||
}
|
||||
}, [maxDurationSeconds, isSupported]);
|
||||
}, [maxDurationSeconds, isSupported, platform]);
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
if (!isRecording || !isTauri()) {
|
||||
if (!isRecording || !platform.metadata.isTauri) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,17 +90,9 @@ export function useSystemAudioCapture({
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// Stop capture and get base64 WAV data
|
||||
const base64Data = await invoke<string>('stop_system_audio_capture');
|
||||
// Stop capture and get Blob
|
||||
const blob = await platform.audio.stopSystemAudioCapture();
|
||||
|
||||
// Convert base64 to Blob
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([bytes], { type: 'audio/wav' });
|
||||
// Pass the actual recorded duration
|
||||
const recordedDuration = startTimeRef.current
|
||||
? (Date.now() - startTimeRef.current) / 1000
|
||||
@@ -125,7 +105,7 @@ export function useSystemAudioCapture({
|
||||
: 'Failed to stop system audio capture.';
|
||||
setError(errorMessage);
|
||||
}
|
||||
}, [isRecording, onRecordingComplete]);
|
||||
}, [isRecording, onRecordingComplete, platform]);
|
||||
|
||||
// Store stopRecording in ref for use in timer
|
||||
useEffect(() => {
|
||||
@@ -155,15 +135,15 @@ export function useSystemAudioCapture({
|
||||
timerRef.current = null;
|
||||
}
|
||||
// Cancel recording on unmount if still recording
|
||||
if (isRecordingRef.current && isTauri()) {
|
||||
if (isRecordingRef.current && platform.metadata.isTauri) {
|
||||
// Call stop directly without the callback to avoid stale closure
|
||||
invoke('stop_system_audio_capture').catch((err) => {
|
||||
platform.audio.stopSystemAudioCapture().catch((err) => {
|
||||
console.error('Error stopping audio capture on unmount:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on unmount
|
||||
}, []);
|
||||
}, [platform]);
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Tauri integration utilities
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, emit } from '@tauri-apps/api/event';
|
||||
|
||||
/**
|
||||
* Check if running in Tauri environment
|
||||
*/
|
||||
export function isTauri(): boolean {
|
||||
return '__TAURI_INTERNALS__' in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running on macOS
|
||||
*/
|
||||
export function isMacOS(): boolean {
|
||||
return navigator.platform.toLowerCase().includes('mac');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the bundled Python server (Tauri only)
|
||||
*/
|
||||
export async function startServer(remote = false): Promise<string> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri environment');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invoke<string>('start_server', { remote });
|
||||
console.log('Server started:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the bundled Python server (Tauri only)
|
||||
*/
|
||||
export async function stopServer(): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri environment');
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('stop_server');
|
||||
console.log('Server stopped');
|
||||
} catch (error) {
|
||||
console.error('Failed to stop server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the server should keep running when the app closes (Tauri only)
|
||||
*/
|
||||
export async function setKeepServerRunning(keepRunning: boolean): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('set_keep_server_running', { keepRunning });
|
||||
} catch (error) {
|
||||
console.error('Failed to set keep server running setting:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup window close handler to check setting and stop server if needed
|
||||
*/
|
||||
export async function setupWindowCloseHandler(): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Listen for window close request from Rust
|
||||
await listen<null>('window-close-requested', async () => {
|
||||
// Import store here to avoid circular dependency
|
||||
const { useServerStore } = await import('@/stores/serverStore');
|
||||
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
||||
|
||||
// Check if server was started by this app instance
|
||||
// In dev mode, serverStartedByApp will be false, so we won't try to stop a separately-run server
|
||||
// We need to access the module-level variable - this is a bit hacky but works
|
||||
// @ts-expect-error - accessing module-level variable from another module
|
||||
const serverStartedByApp = window.__voiceboxServerStartedByApp ?? false;
|
||||
|
||||
if (!keepRunning && serverStartedByApp) {
|
||||
// Stop server before closing (only if we started it)
|
||||
try {
|
||||
await stopServer();
|
||||
} catch (error) {
|
||||
console.error('Failed to stop server on close:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit event back to Rust to allow close
|
||||
await emit('window-close-allowed');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to setup window close handler:', error);
|
||||
}
|
||||
}
|
||||
+36
-18
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import type { Platform } from './types';
|
||||
|
||||
const PlatformContext = createContext<Platform | null>(null);
|
||||
|
||||
export interface PlatformProviderProps {
|
||||
platform: Platform;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PlatformProvider({ platform, children }: PlatformProviderProps) {
|
||||
return (
|
||||
<PlatformContext.Provider value={platform}>
|
||||
{children}
|
||||
</PlatformContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePlatform(): Platform {
|
||||
const platform = useContext(PlatformContext);
|
||||
if (!platform) {
|
||||
throw new Error('usePlatform must be used within PlatformProvider');
|
||||
}
|
||||
return platform;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Platform abstraction types
|
||||
* These interfaces define the contract that platform implementations must fulfill
|
||||
*/
|
||||
|
||||
export interface FileFilter {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
export interface PlatformFilesystem {
|
||||
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
|
||||
}
|
||||
|
||||
export interface UpdateStatus {
|
||||
checking: boolean;
|
||||
available: boolean;
|
||||
version?: string;
|
||||
downloading: boolean;
|
||||
installing: boolean;
|
||||
readyToInstall: boolean;
|
||||
error?: string;
|
||||
downloadProgress?: number; // 0-100 percentage
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
}
|
||||
|
||||
export interface PlatformUpdater {
|
||||
checkForUpdates(): Promise<void>;
|
||||
downloadAndInstall(): Promise<void>;
|
||||
restartAndInstall(): Promise<void>;
|
||||
getStatus(): UpdateStatus;
|
||||
subscribe(callback: (status: UpdateStatus) => void): () => void;
|
||||
}
|
||||
|
||||
export interface AudioDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformAudio {
|
||||
isSystemAudioSupported(): boolean;
|
||||
startSystemAudioCapture(maxDurationSecs: number): Promise<void>;
|
||||
stopSystemAudioCapture(): Promise<Blob>;
|
||||
listOutputDevices(): Promise<AudioDevice[]>;
|
||||
playToDevices(audioData: Uint8Array, deviceIds: string[]): Promise<void>;
|
||||
stopPlayback(): void;
|
||||
}
|
||||
|
||||
export interface PlatformLifecycle {
|
||||
startServer(remote?: boolean): Promise<string>;
|
||||
stopServer(): Promise<void>;
|
||||
restartServer(): Promise<string>;
|
||||
setKeepServerRunning(keep: boolean): Promise<void>;
|
||||
setupWindowCloseHandler(): Promise<void>;
|
||||
onServerReady?: () => void;
|
||||
}
|
||||
|
||||
export interface PlatformMetadata {
|
||||
getVersion(): Promise<string>;
|
||||
isTauri: boolean;
|
||||
}
|
||||
|
||||
export interface Platform {
|
||||
filesystem: PlatformFilesystem;
|
||||
updater: PlatformUpdater;
|
||||
audio: PlatformAudio;
|
||||
lifecycle: PlatformLifecycle;
|
||||
metadata: PlatformMetadata;
|
||||
}
|
||||
+2
-1
@@ -10,7 +10,8 @@ import { Toaster } from '@/components/ui/toaster';
|
||||
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
||||
import { isMacOS } from '@/lib/tauri';
|
||||
// Simple platform check that works in both web and Tauri
|
||||
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
||||
|
||||
// Root layout component
|
||||
function RootLayout() {
|
||||
|
||||
+12
-9
@@ -334,18 +334,21 @@ python -m backend.main --host 0.0.0.0 --port 8000
|
||||
|
||||
## Usage Examples
|
||||
|
||||
The desktop app, web client, and current development workflow use `http://localhost:17493` by default.
|
||||
If you launch the backend manually with a different host or port, substitute that address in the examples below.
|
||||
|
||||
### Creating a Voice Profile
|
||||
|
||||
```bash
|
||||
# 1. Create profile
|
||||
curl -X POST http://localhost:8000/profiles \
|
||||
curl -X POST http://localhost:17493/profiles \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "My Voice", "language": "en"}'
|
||||
|
||||
# Response: {"id": "abc-123", ...}
|
||||
|
||||
# 2. Add sample
|
||||
curl -X POST http://localhost:8000/profiles/abc-123/samples \
|
||||
curl -X POST http://localhost:17493/profiles/abc-123/samples \
|
||||
-F "file=@sample.wav" \
|
||||
-F "reference_text=This is my voice sample"
|
||||
```
|
||||
@@ -353,7 +356,7 @@ curl -X POST http://localhost:8000/profiles/abc-123/samples \
|
||||
### Generating Speech
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/generate \
|
||||
curl -X POST http://localhost:17493/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"profile_id": "abc-123",
|
||||
@@ -365,13 +368,13 @@ curl -X POST http://localhost:8000/generate \
|
||||
# Response: {"id": "gen-456", "audio_path": "/path/to/audio.wav", ...}
|
||||
|
||||
# Download audio
|
||||
curl http://localhost:8000/audio/gen-456 -o output.wav
|
||||
curl http://localhost:17493/audio/gen-456 -o output.wav
|
||||
```
|
||||
|
||||
### Transcribing Audio
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/transcribe \
|
||||
curl -X POST http://localhost:17493/transcribe \
|
||||
-F "file=@audio.wav" \
|
||||
-F "language=en"
|
||||
|
||||
@@ -386,12 +389,12 @@ Add multiple samples to a profile for better quality:
|
||||
|
||||
```bash
|
||||
# Add first sample
|
||||
curl -X POST http://localhost:8000/profiles/abc-123/samples \
|
||||
curl -X POST http://localhost:17493/profiles/abc-123/samples \
|
||||
-F "file=@sample1.wav" \
|
||||
-F "reference_text=First sample"
|
||||
|
||||
# Add second sample
|
||||
curl -X POST http://localhost:8000/profiles/abc-123/samples \
|
||||
curl -X POST http://localhost:17493/profiles/abc-123/samples \
|
||||
-F "file=@sample2.wav" \
|
||||
-F "reference_text=Second sample"
|
||||
|
||||
@@ -412,10 +415,10 @@ Models are lazy-loaded and can be manually unloaded:
|
||||
|
||||
```bash
|
||||
# Unload TTS model
|
||||
curl -X POST http://localhost:8000/models/unload
|
||||
curl -X POST http://localhost:17493/models/unload
|
||||
|
||||
# Load specific model size
|
||||
curl -X POST "http://localhost:8000/models/load?model_size=0.6B"
|
||||
curl -X POST "http://localhost:17493/models/load?model_size=0.6B"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Backend package
|
||||
|
||||
__version__ = "0.1.10"
|
||||
__version__ = "0.1.13"
|
||||
|
||||
@@ -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,69 @@ 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",
|
||||
"chatterbox": "Chatterbox TTS",
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
elif engine == "chatterbox":
|
||||
from .chatterbox_backend import ChatterboxTTSBackend
|
||||
backend = ChatterboxTTSBackend()
|
||||
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 +202,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
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Chatterbox TTS backend implementation.
|
||||
|
||||
Wraps ChatterboxMultilingualTTS from chatterbox-tts for zero-shot
|
||||
voice cloning. Supports 23 languages including Hebrew. Forces CPU
|
||||
on macOS due to known MPS tensor issues.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import platform
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import TTSBackend
|
||||
from ..utils.audio import normalize_audio, load_audio
|
||||
from ..utils.progress import get_progress_manager
|
||||
from ..utils.tasks import get_task_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHATTERBOX_HF_REPO = "ResembleAI/chatterbox"
|
||||
|
||||
# Files that must be present for the multilingual model
|
||||
_MTL_WEIGHT_FILES = [
|
||||
"t3_mtl23ls_v2.safetensors",
|
||||
"s3gen.pt",
|
||||
"ve.pt",
|
||||
]
|
||||
|
||||
|
||||
class ChatterboxTTSBackend:
|
||||
"""Chatterbox Multilingual TTS backend for voice cloning."""
|
||||
|
||||
# Class-level lock for torch.load monkey-patching
|
||||
_load_lock: ClassVar[threading.Lock] = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
self.model = None
|
||||
self.model_size = "default"
|
||||
self._device = None
|
||||
self._model_load_lock = asyncio.Lock()
|
||||
|
||||
def _get_device(self) -> str:
|
||||
"""Get the best available device. Forces CPU on macOS (MPS issue)."""
|
||||
if platform.system() == "Darwin":
|
||||
return "cpu"
|
||||
try:
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
except ImportError:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
return self.model is not None
|
||||
|
||||
def _get_model_path(self, model_size: str = "default") -> str:
|
||||
return CHATTERBOX_HF_REPO
|
||||
|
||||
def _is_model_cached(self, model_size: str = "default") -> bool:
|
||||
"""Check if the Chatterbox multilingual model is cached locally."""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
|
||||
repo_cache = Path(hf_constants.HF_HUB_CACHE) / (
|
||||
"models--" + CHATTERBOX_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
|
||||
|
||||
# Check for multilingual weight files
|
||||
snapshots_dir = repo_cache / "snapshots"
|
||||
if snapshots_dir.exists():
|
||||
for fname in _MTL_WEIGHT_FILES:
|
||||
if not any(snapshots_dir.rglob(fname)):
|
||||
return False
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking Chatterbox cache: {e}")
|
||||
return False
|
||||
|
||||
async def load_model(self, model_size: str = "default") -> None:
|
||||
"""Load the Chatterbox multilingual model."""
|
||||
if self.model is not None:
|
||||
return
|
||||
async with self._model_load_lock:
|
||||
if self.model is not None:
|
||||
return
|
||||
await asyncio.to_thread(self._load_model_sync)
|
||||
|
||||
def _load_model_sync(self):
|
||||
"""Synchronous model loading."""
|
||||
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
model_name = "chatterbox-tts"
|
||||
|
||||
is_cached = self._is_model_cached()
|
||||
|
||||
# Set up HF progress tracking (intercepts tqdm for file-level progress)
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
|
||||
if not is_cached:
|
||||
task_manager.start_download(model_name)
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=0,
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
try:
|
||||
device = self._get_device()
|
||||
self._device = device
|
||||
|
||||
logger.info(f"Loading Chatterbox Multilingual TTS on {device}...")
|
||||
|
||||
import torch
|
||||
from chatterbox.mtl_tts import ChatterboxMultilingualTTS
|
||||
|
||||
# Monkey-patch torch.load for CPU loading. The model's .pt files
|
||||
# were saved on CUDA; from_pretrained() doesn't pass map_location
|
||||
# so loading on CPU fails without this.
|
||||
try:
|
||||
if device == "cpu":
|
||||
_orig_torch_load = torch.load
|
||||
|
||||
def _patched_load(*args, **kwargs):
|
||||
kwargs.setdefault("map_location", "cpu")
|
||||
return _orig_torch_load(*args, **kwargs)
|
||||
|
||||
with ChatterboxTTSBackend._load_lock:
|
||||
torch.load = _patched_load
|
||||
try:
|
||||
self.model = ChatterboxMultilingualTTS.from_pretrained(
|
||||
device=device,
|
||||
)
|
||||
finally:
|
||||
torch.load = _orig_torch_load
|
||||
else:
|
||||
self.model = ChatterboxMultilingualTTS.from_pretrained(
|
||||
device=device,
|
||||
)
|
||||
finally:
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
# Fix: transformers >= 4.36 defaults LlamaModel to sdpa attention
|
||||
# which doesn't support output_attentions=True (needed by
|
||||
# Chatterbox's AlignmentStreamAnalyzer). Force eager attention.
|
||||
t3_tfmr = self.model.t3.tfmr
|
||||
if hasattr(t3_tfmr, "config") and hasattr(
|
||||
t3_tfmr.config, "_attn_implementation"
|
||||
):
|
||||
t3_tfmr.config._attn_implementation = "eager"
|
||||
for layer in getattr(t3_tfmr, "layers", []):
|
||||
if hasattr(layer, "self_attn"):
|
||||
layer.self_attn._attn_implementation = "eager"
|
||||
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
|
||||
logger.info("Chatterbox Multilingual TTS loaded successfully")
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(
|
||||
"chatterbox-tts package not found. "
|
||||
"Install with: pip install chatterbox-tts"
|
||||
)
|
||||
if not is_cached:
|
||||
progress_manager.mark_error(model_name, str(e))
|
||||
task_manager.error_download(model_name, str(e))
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load Chatterbox: {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:
|
||||
device = self._device
|
||||
del self.model
|
||||
self.model = None
|
||||
self._device = None
|
||||
if device == "cuda":
|
||||
import torch
|
||||
|
||||
torch.cuda.empty_cache()
|
||||
logger.info("Chatterbox 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.
|
||||
|
||||
Chatterbox processes reference audio at generation time, so the
|
||||
prompt just stores the file path. The actual audio is loaded by
|
||||
model.generate() via audio_prompt_path.
|
||||
"""
|
||||
voice_prompt = {
|
||||
"ref_audio": str(audio_path),
|
||||
"ref_text": reference_text,
|
||||
}
|
||||
return voice_prompt, False
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: List[str],
|
||||
reference_texts: List[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
"""Combine multiple reference samples."""
|
||||
combined_audio = []
|
||||
for path in audio_paths:
|
||||
audio, _sr = load_audio(path)
|
||||
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
|
||||
|
||||
# Per-language generation defaults. Lower temp + higher cfg = clearer speech.
|
||||
_LANG_DEFAULTS: ClassVar[dict] = {
|
||||
"he": {
|
||||
"exaggeration": 0.4,
|
||||
"cfg_weight": 0.7,
|
||||
"temperature": 0.65,
|
||||
"repetition_penalty": 2.5,
|
||||
},
|
||||
}
|
||||
_GLOBAL_DEFAULTS: ClassVar[dict] = {
|
||||
"exaggeration": 0.5,
|
||||
"cfg_weight": 0.5,
|
||||
"temperature": 0.8,
|
||||
"repetition_penalty": 2.0,
|
||||
}
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str = "en",
|
||||
seed: Optional[int] = None,
|
||||
instruct: Optional[str] = None,
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
"""
|
||||
Generate audio using Chatterbox Multilingual TTS.
|
||||
|
||||
Args:
|
||||
text: Text to synthesize
|
||||
voice_prompt: Dict with ref_audio path
|
||||
language: BCP-47 language code
|
||||
seed: Random seed for reproducibility
|
||||
instruct: Unused (protocol compatibility)
|
||||
|
||||
Returns:
|
||||
Tuple of (audio_array, sample_rate)
|
||||
"""
|
||||
await self.load_model()
|
||||
|
||||
ref_audio = voice_prompt.get("ref_audio")
|
||||
if ref_audio and not Path(ref_audio).exists():
|
||||
logger.warning(f"Reference audio not found: {ref_audio}")
|
||||
ref_audio = None
|
||||
|
||||
# Merge language-specific defaults with global defaults
|
||||
lang_defaults = self._LANG_DEFAULTS.get(language, self._GLOBAL_DEFAULTS)
|
||||
|
||||
def _generate_sync():
|
||||
import torch
|
||||
|
||||
if seed is not None:
|
||||
torch.manual_seed(seed)
|
||||
|
||||
logger.info(f"[Chatterbox] Generating: lang={language}")
|
||||
|
||||
wav = self.model.generate(
|
||||
text,
|
||||
language_id=language,
|
||||
audio_prompt_path=ref_audio,
|
||||
exaggeration=lang_defaults["exaggeration"],
|
||||
cfg_weight=lang_defaults["cfg_weight"],
|
||||
temperature=lang_defaults["temperature"],
|
||||
repetition_penalty=lang_defaults["repetition_penalty"],
|
||||
)
|
||||
|
||||
# Convert tensor -> numpy
|
||||
if isinstance(wav, torch.Tensor):
|
||||
audio = wav.squeeze().cpu().numpy().astype(np.float32)
|
||||
else:
|
||||
audio = np.asarray(wav, dtype=np.float32)
|
||||
|
||||
sample_rate = (
|
||||
getattr(self.model, "sr", None)
|
||||
or getattr(self.model, "sample_rate", 24000)
|
||||
)
|
||||
|
||||
return audio, sample_rate
|
||||
|
||||
return await asyncio.to_thread(_generate_sync)
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
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."""
|
||||
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
model_name = "luxtts"
|
||||
|
||||
is_cached = self._is_model_cached()
|
||||
|
||||
# Set up HF progress tracking (intercepts tqdm for file-level progress)
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
|
||||
if not is_cached:
|
||||
task_manager.start_download(model_name)
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=0,
|
||||
filename="Connecting to HuggingFace...",
|
||||
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
|
||||
try:
|
||||
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,
|
||||
)
|
||||
finally:
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
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)
|
||||
+207
-71
@@ -52,6 +52,47 @@ class MLXTTSBackend:
|
||||
|
||||
return hf_model_id
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
"""
|
||||
Check if the model is already cached locally AND fully downloaded.
|
||||
|
||||
Args:
|
||||
model_size: Model size to check
|
||||
|
||||
Returns:
|
||||
True if model is fully cached, False if missing or incomplete
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
model_path = self._get_model_path(model_size)
|
||||
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.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 {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():
|
||||
has_weights = (
|
||||
any(snapshots_dir.rglob("*.safetensors")) or
|
||||
any(snapshots_dir.rglob("*.bin")) or
|
||||
any(snapshots_dir.rglob("*.npz"))
|
||||
)
|
||||
if not has_weights:
|
||||
print(f"[_is_model_cached] No model weights found for {model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[_is_model_cached] Error checking cache for {model_size}: {e}")
|
||||
return False
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
"""
|
||||
Lazy load the MLX TTS model.
|
||||
@@ -79,46 +120,63 @@ class MLXTTSBackend:
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
try:
|
||||
from mlx_audio.tts import load
|
||||
|
||||
# Get model path
|
||||
# Get model path BEFORE importing mlx_audio
|
||||
model_path = self._get_model_path(model_size)
|
||||
|
||||
# Set up progress tracking
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
# Start tracking download task
|
||||
task_manager = get_task_manager()
|
||||
task_manager.start_download(model_name)
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback
|
||||
# If cached: filter out non-download progress
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
print(f"Loading MLX TTS model {model_size}...")
|
||||
|
||||
# Initialize progress state
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=1,
|
||||
filename="",
|
||||
status="downloading",
|
||||
)
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
# This provides immediate feedback while HuggingFace fetches metadata
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=0, # Will be updated once actual total is known
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Set up progress callback
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
# IMPORTANT: Patch tqdm BEFORE importing mlx_audio
|
||||
# Otherwise mlx_audio caches reference to original tqdm
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
|
||||
# Use progress tracker during download
|
||||
with tracker.patch_download():
|
||||
# Load MLX model (downloads automatically)
|
||||
# Import mlx_audio AFTER patching tqdm
|
||||
from mlx_audio.tts import load
|
||||
|
||||
# Load MLX model (downloads automatically)
|
||||
try:
|
||||
self.model = load(model_path)
|
||||
finally:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
|
||||
# Mark as complete
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
|
||||
print(f"MLX TTS model {model_size} loaded successfully")
|
||||
|
||||
except ImportError as e:
|
||||
@@ -175,7 +233,13 @@ class MLXTTSBackend:
|
||||
if cached_prompt is not None:
|
||||
# Return cached prompt (should be dict format)
|
||||
if isinstance(cached_prompt, dict):
|
||||
return cached_prompt, True
|
||||
# Validate that the cached audio file still exists
|
||||
cached_audio_path = cached_prompt.get("ref_audio") or cached_prompt.get("ref_audio_path")
|
||||
if cached_audio_path and Path(cached_audio_path).exists():
|
||||
return cached_prompt, True
|
||||
else:
|
||||
# Cached file no longer exists, invalidate cache
|
||||
print(f"Cached audio file not found: {cached_audio_path}, regenerating prompt")
|
||||
|
||||
# MLX voice prompt format - store audio path and text
|
||||
# The model will process this during generation
|
||||
@@ -263,6 +327,13 @@ class MLXTTSBackend:
|
||||
ref_audio = voice_prompt.get("ref_audio") or voice_prompt.get("ref_audio_path")
|
||||
ref_text = voice_prompt.get("ref_text", "")
|
||||
|
||||
# Validate that the audio file exists
|
||||
if ref_audio and not Path(ref_audio).exists():
|
||||
print(f"Warning: Audio file not found: {ref_audio}")
|
||||
print("This may be due to a cached voice prompt referencing a deleted temp file.")
|
||||
print("Regenerating without voice prompt.")
|
||||
ref_audio = None
|
||||
|
||||
# Check if model supports voice cloning via generate method
|
||||
# MLX API may support ref_audio parameter directly
|
||||
try:
|
||||
@@ -308,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
|
||||
@@ -319,6 +398,47 @@ class MLXSTTBackend:
|
||||
"""Check if model is loaded."""
|
||||
return self.model is not None
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
"""
|
||||
Check if the Whisper model is already cached locally AND fully downloaded.
|
||||
|
||||
Args:
|
||||
model_size: Model size to check
|
||||
|
||||
Returns:
|
||||
True if model is fully cached, False if missing or incomplete
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
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():
|
||||
has_weights = (
|
||||
any(snapshots_dir.rglob("*.safetensors")) or
|
||||
any(snapshots_dir.rglob("*.bin")) or
|
||||
any(snapshots_dir.rglob("*.npz"))
|
||||
)
|
||||
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 MLX Whisper model.
|
||||
@@ -341,44 +461,59 @@ class MLXSTTBackend:
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
try:
|
||||
from mlx_audio.asr import load
|
||||
|
||||
# MLX Whisper model naming
|
||||
model_name = f"mlx-community/whisper-{model_size}"
|
||||
|
||||
# Set up progress tracking
|
||||
progress_manager = get_progress_manager()
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
|
||||
# Start tracking download task
|
||||
task_manager = get_task_manager()
|
||||
task_manager.start_download(progress_model_name)
|
||||
|
||||
print(f"Loading MLX Whisper model {model_size}...")
|
||||
|
||||
# Initialize progress state
|
||||
progress_manager.update_progress(
|
||||
model_name=progress_model_name,
|
||||
current=0,
|
||||
total=1,
|
||||
filename="",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Set up progress callback
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback and tracker
|
||||
# If cached: filter out non-download progress
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
|
||||
# Use progress tracker during download
|
||||
with tracker.patch_download():
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
# Patch tqdm BEFORE importing mlx_audio
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
|
||||
# Import mlx_audio
|
||||
from mlx_audio.stt import load
|
||||
|
||||
# MLX Whisper uses the standard OpenAI models
|
||||
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||
|
||||
print(f"Loading MLX Whisper model {model_size}...")
|
||||
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(progress_model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
progress_manager.update_progress(
|
||||
model_name=progress_model_name,
|
||||
current=0,
|
||||
total=0,
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||
try:
|
||||
self.model = load(model_name)
|
||||
finally:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(progress_model_name)
|
||||
task_manager.complete_download(progress_model_name)
|
||||
|
||||
self.model_size = model_size
|
||||
|
||||
# Mark as complete
|
||||
progress_manager.mark_complete(progress_model_name)
|
||||
task_manager.complete_download(progress_model_name)
|
||||
|
||||
print(f"MLX Whisper model {model_size} loaded successfully")
|
||||
|
||||
except ImportError as e:
|
||||
@@ -412,34 +547,35 @@ class MLXSTTBackend:
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio to text.
|
||||
|
||||
|
||||
Args:
|
||||
audio_path: Path to audio file
|
||||
language: Optional language hint (en or zh)
|
||||
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
"""
|
||||
await self.load_model_async(None)
|
||||
|
||||
|
||||
def _transcribe_sync():
|
||||
"""Run synchronous transcription in thread pool."""
|
||||
# Load audio
|
||||
audio, sr = load_audio(audio_path, sample_rate=16000)
|
||||
|
||||
# MLX Whisper transcription
|
||||
# The API may vary - check mlx-audio documentation
|
||||
# For now, assuming similar API to PyTorch Whisper
|
||||
result = self.model.transcribe(audio, language=language)
|
||||
|
||||
# Extract text from result (format may vary)
|
||||
# MLX Whisper transcription using generate method
|
||||
# The generate method accepts audio path directly
|
||||
decode_options = {}
|
||||
if language:
|
||||
decode_options["language"] = language
|
||||
|
||||
result = self.model.generate(str(audio_path), **decode_options)
|
||||
|
||||
# Extract text from result
|
||||
if isinstance(result, str):
|
||||
return result.strip()
|
||||
elif isinstance(result, dict):
|
||||
return result.get("text", "").strip()
|
||||
elif hasattr(result, "text"):
|
||||
return result.text.strip()
|
||||
else:
|
||||
# Try to get text attribute
|
||||
return str(result).strip()
|
||||
|
||||
|
||||
# Run blocking transcription in thread pool
|
||||
return await asyncio.to_thread(_transcribe_sync)
|
||||
|
||||
@@ -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:
|
||||
@@ -58,6 +72,46 @@ class PyTorchTTSBackend:
|
||||
|
||||
return hf_model_map[model_size]
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
"""
|
||||
Check if the model is already cached locally AND fully downloaded.
|
||||
|
||||
Args:
|
||||
model_size: Model size to check
|
||||
|
||||
Returns:
|
||||
True if model is fully cached, False if missing or incomplete
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
model_path = self._get_model_path(model_size)
|
||||
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.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 {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():
|
||||
has_weights = (
|
||||
any(snapshots_dir.rglob("*.safetensors")) or
|
||||
any(snapshots_dir.rglob("*.bin"))
|
||||
)
|
||||
if not has_weights:
|
||||
print(f"[_is_model_cached] No model weights found for {model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[_is_model_cached] Error checking cache for {model_size}: {e}")
|
||||
return False
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
"""
|
||||
Lazy load the TTS model with automatic downloading from HuggingFace Hub.
|
||||
@@ -85,46 +139,70 @@ class PyTorchTTSBackend:
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
try:
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback and tracker
|
||||
# If cached: filter out non-download progress (like "Segment 1/1" during generation)
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
# Patch tqdm BEFORE importing qwen_tts
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
|
||||
# Import qwen_tts
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
|
||||
# Get model path (local or HuggingFace Hub ID)
|
||||
model_path = self._get_model_path(model_size)
|
||||
|
||||
# Set up progress tracking
|
||||
progress_manager = get_progress_manager()
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
|
||||
print(f"Loading TTS model {model_size} on {self.device}...")
|
||||
|
||||
# Start tracking download task
|
||||
task_manager = get_task_manager()
|
||||
task_manager.start_download(model_name)
|
||||
|
||||
# Initialize progress state to show download has started
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=1, # Set to 1 initially, will be updated by callback
|
||||
filename="",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Set up progress callback
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
|
||||
# Use progress tracker during download
|
||||
with tracker.patch_download():
|
||||
# Load the model - downloads will happen automatically with progress tracking
|
||||
self.model = Qwen3TTSModel.from_pretrained(
|
||||
model_path,
|
||||
device_map=self.device,
|
||||
torch_dtype=torch.float32 if self.device == "cpu" else torch.bfloat16,
|
||||
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=0, # Will be updated once actual total is known
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||
try:
|
||||
# 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)
|
||||
|
||||
# Mark as complete
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
@@ -188,6 +266,8 @@ class PyTorchTTSBackend:
|
||||
# Cache stores as torch.Tensor but actual prompt is dict
|
||||
# Convert if needed
|
||||
if isinstance(cached_prompt, dict):
|
||||
# For PyTorch backend, the dict should contain tensors, not file paths
|
||||
# So we can safely return it
|
||||
return cached_prompt, True
|
||||
elif isinstance(cached_prompt, torch.Tensor):
|
||||
# Legacy cache format - convert to dict
|
||||
@@ -289,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
|
||||
@@ -302,76 +390,153 @@ 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:
|
||||
"""Check if model is loaded."""
|
||||
return self.model is not None
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
"""
|
||||
Check if the Whisper model is already cached locally AND fully downloaded.
|
||||
|
||||
Args:
|
||||
model_size: Model size to check
|
||||
|
||||
Returns:
|
||||
True if model is fully cached, False if missing or incomplete
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
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():
|
||||
has_weights = (
|
||||
any(snapshots_dir.rglob("*.safetensors")) or
|
||||
any(snapshots_dir.rglob("*.bin"))
|
||||
)
|
||||
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.
|
||||
|
||||
|
||||
Args:
|
||||
model_size: Model size (tiny, base, small, medium, large)
|
||||
"""
|
||||
print(f"[DEBUG] load_model_async called with size: {model_size}")
|
||||
if model_size is None:
|
||||
model_size = self.model_size
|
||||
|
||||
|
||||
print(f"[DEBUG] Model already loaded? {self.model is not None}, current size: {self.model_size}, requested: {model_size}")
|
||||
if self.model is not None and self.model_size == model_size:
|
||||
print(f"[DEBUG] Early return - model already loaded")
|
||||
return
|
||||
|
||||
|
||||
print(f"[DEBUG] Calling asyncio.to_thread for _load_model_sync")
|
||||
# Run blocking load in thread pool
|
||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||
print(f"[DEBUG] asyncio.to_thread completed")
|
||||
|
||||
# Alias for compatibility
|
||||
load_model = load_model_async
|
||||
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
print(f"[DEBUG] _load_model_sync called for Whisper {model_size}")
|
||||
try:
|
||||
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||
|
||||
model_name = f"openai/whisper-{model_size}"
|
||||
|
||||
# Set up progress tracking
|
||||
progress_manager = get_progress_manager()
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
|
||||
# Start tracking download task
|
||||
task_manager = get_task_manager()
|
||||
task_manager.start_download(progress_model_name)
|
||||
|
||||
print(f"Loading Whisper model {model_size} on {self.device}...")
|
||||
|
||||
# Initialize progress state to show download has started
|
||||
progress_manager.update_progress(
|
||||
model_name=progress_model_name,
|
||||
current=0,
|
||||
total=1, # Set to 1 initially, will be updated by callback
|
||||
filename="",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Set up progress callback
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback and tracker
|
||||
# If cached: filter out non-download progress
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
|
||||
# Use progress tracker during download
|
||||
with tracker.patch_download():
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
# Patch tqdm BEFORE importing transformers
|
||||
print("[DEBUG] Starting tqdm patch BEFORE transformers import")
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
print("[DEBUG] tqdm patched, now importing transformers")
|
||||
|
||||
# Import transformers
|
||||
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||
|
||||
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}...")
|
||||
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(progress_model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
progress_manager.update_progress(
|
||||
model_name=progress_model_name,
|
||||
current=0,
|
||||
total=0, # Will be updated once actual total is known
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Load models (tqdm is patched, but filters out non-download progress)
|
||||
try:
|
||||
self.processor = WhisperProcessor.from_pretrained(model_name)
|
||||
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
||||
finally:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(progress_model_name)
|
||||
task_manager.complete_download(progress_model_name)
|
||||
|
||||
self.model.to(self.device)
|
||||
self.model_size = model_size
|
||||
|
||||
# Mark as complete
|
||||
progress_manager.mark_complete(progress_model_name)
|
||||
task_manager.complete_download(progress_model_name)
|
||||
|
||||
print(f"Whisper model {model_size} loaded successfully")
|
||||
|
||||
except Exception as e:
|
||||
|
||||
+44
-12
@@ -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',
|
||||
@@ -80,14 +101,18 @@ def build_server():
|
||||
'--hidden-import', 'mlx.nn',
|
||||
'--hidden-import', 'mlx_audio',
|
||||
'--hidden-import', 'mlx_audio.tts',
|
||||
'--hidden-import', 'mlx_audio.asr',
|
||||
'--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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
+654
-83
File diff suppressed because it is too large
Load Diff
+27
-2
@@ -11,7 +11,7 @@ class VoiceProfileCreate(BaseModel):
|
||||
"""Request model for creating a voice profile."""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it)$")
|
||||
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he)$")
|
||||
|
||||
|
||||
class VoiceProfileResponse(BaseModel):
|
||||
@@ -53,10 +53,11 @@ class GenerationRequest(BaseModel):
|
||||
"""Request model for voice generation."""
|
||||
profile_id: str
|
||||
text: str = Field(..., min_length=1, max_length=5000)
|
||||
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it)$")
|
||||
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he)$")
|
||||
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|chatterbox)$")
|
||||
|
||||
|
||||
class GenerationResponse(BaseModel):
|
||||
@@ -127,13 +128,32 @@ 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 DirectoryCheck(BaseModel):
|
||||
"""Health status for a single directory."""
|
||||
path: str
|
||||
exists: bool
|
||||
writable: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class FilesystemHealthResponse(BaseModel):
|
||||
"""Response model for filesystem health check."""
|
||||
healthy: bool
|
||||
disk_free_mb: Optional[float] = None
|
||||
disk_total_mb: Optional[float] = None
|
||||
directories: List[DirectoryCheck]
|
||||
|
||||
|
||||
class ModelStatus(BaseModel):
|
||||
"""Response model for model status."""
|
||||
model_name: str
|
||||
display_name: str
|
||||
hf_repo_id: Optional[str] = None # HuggingFace repository ID
|
||||
downloaded: bool
|
||||
downloading: bool = False # True if download is in progress
|
||||
size_mb: Optional[float] = None
|
||||
loaded: bool = False
|
||||
|
||||
@@ -153,6 +173,11 @@ class ActiveDownloadTask(BaseModel):
|
||||
model_name: str
|
||||
status: str
|
||||
started_at: datetime
|
||||
error: Optional[str] = None
|
||||
progress: Optional[float] = None # 0-100 percentage
|
||||
current: Optional[int] = None # bytes downloaded
|
||||
total: Optional[int] = None # total bytes
|
||||
filename: Optional[str] = None # current file being downloaded
|
||||
|
||||
|
||||
class ActiveGenerationTask(BaseModel):
|
||||
|
||||
@@ -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"
|
||||
|
||||
+74
-27
@@ -22,6 +22,7 @@ from .database import (
|
||||
)
|
||||
from .utils.audio import validate_reference_audio, load_audio, save_audio
|
||||
from .utils.images import validate_image, process_avatar
|
||||
from .utils.cache import _get_cache_dir, clear_profile_cache
|
||||
from .tts import get_tts_model
|
||||
from . import config
|
||||
|
||||
@@ -37,14 +38,22 @@ async def create_profile(
|
||||
) -> VoiceProfileResponse:
|
||||
"""
|
||||
Create a new voice profile.
|
||||
|
||||
|
||||
Args:
|
||||
data: Profile creation data
|
||||
db: Database session
|
||||
|
||||
|
||||
Returns:
|
||||
Created profile
|
||||
|
||||
Raises:
|
||||
ValueError: If a profile with the same name already exists
|
||||
"""
|
||||
# Check if profile name already exists
|
||||
existing_profile = db.query(DBVoiceProfile).filter_by(name=data.name).first()
|
||||
if existing_profile:
|
||||
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
|
||||
|
||||
# Create profile in database
|
||||
db_profile = DBVoiceProfile(
|
||||
id=str(uuid.uuid4()),
|
||||
@@ -54,15 +63,15 @@ async def create_profile(
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
db.add(db_profile)
|
||||
db.commit()
|
||||
db.refresh(db_profile)
|
||||
|
||||
|
||||
# Create profile directory
|
||||
profile_dir = _get_profiles_dir() / db_profile.id
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
return VoiceProfileResponse.model_validate(db_profile)
|
||||
|
||||
|
||||
@@ -120,6 +129,10 @@ async def add_profile_sample(
|
||||
db.commit()
|
||||
db.refresh(db_sample)
|
||||
|
||||
# Invalidate combined audio cache for this profile
|
||||
# Since a new sample was added, any cached combined audio is now stale
|
||||
clear_profile_cache(profile_id)
|
||||
|
||||
return ProfileSampleResponse.model_validate(db_sample)
|
||||
|
||||
|
||||
@@ -186,28 +199,37 @@ async def update_profile(
|
||||
) -> Optional[VoiceProfileResponse]:
|
||||
"""
|
||||
Update a voice profile.
|
||||
|
||||
|
||||
Args:
|
||||
profile_id: Profile ID
|
||||
data: Updated profile data
|
||||
db: Database session
|
||||
|
||||
|
||||
Returns:
|
||||
Updated profile or None if not found
|
||||
|
||||
Raises:
|
||||
ValueError: If a profile with the same name already exists (different profile)
|
||||
"""
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||
if not profile:
|
||||
return None
|
||||
|
||||
|
||||
# Check if the new name conflicts with another profile
|
||||
if profile.name != data.name:
|
||||
existing_profile = db.query(DBVoiceProfile).filter_by(name=data.name).first()
|
||||
if existing_profile:
|
||||
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
|
||||
|
||||
# Update fields
|
||||
profile.name = data.name
|
||||
profile.description = data.description
|
||||
profile.language = data.language
|
||||
profile.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
db.commit()
|
||||
db.refresh(profile)
|
||||
|
||||
|
||||
return VoiceProfileResponse.model_validate(profile)
|
||||
|
||||
|
||||
@@ -241,6 +263,9 @@ async def delete_profile(
|
||||
if profile_dir.exists():
|
||||
shutil.rmtree(profile_dir)
|
||||
|
||||
# Clean up combined audio cache files for this profile
|
||||
clear_profile_cache(profile_id)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -262,6 +287,9 @@ async def delete_profile_sample(
|
||||
if not sample:
|
||||
return False
|
||||
|
||||
# Store profile_id before deleting
|
||||
profile_id = sample.profile_id
|
||||
|
||||
# Delete audio file
|
||||
audio_path = Path(sample.audio_path)
|
||||
if audio_path.exists():
|
||||
@@ -271,6 +299,10 @@ async def delete_profile_sample(
|
||||
db.delete(sample)
|
||||
db.commit()
|
||||
|
||||
# Invalidate combined audio cache for this profile
|
||||
# Since the sample set changed, any cached combined audio is now stale
|
||||
clear_profile_cache(profile_id)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -294,10 +326,17 @@ async def update_profile_sample(
|
||||
if not sample:
|
||||
return None
|
||||
|
||||
# Store profile_id before updating
|
||||
profile_id = sample.profile_id
|
||||
|
||||
sample.reference_text = reference_text
|
||||
db.commit()
|
||||
db.refresh(sample)
|
||||
|
||||
# Invalidate combined audio cache for this profile
|
||||
# Since the reference text changed, cache keys and combined text are now stale
|
||||
clear_profile_cache(profile_id)
|
||||
|
||||
return ProfileSampleResponse.model_validate(sample)
|
||||
|
||||
|
||||
@@ -305,6 +344,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.
|
||||
@@ -313,17 +353,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
|
||||
@@ -345,23 +388,27 @@ async def create_voice_prompt_for_profile(
|
||||
reference_texts,
|
||||
)
|
||||
|
||||
# Save combined audio temporarily
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
save_audio(combined_audio, tmp.name, 24000)
|
||||
tmp_path = tmp.name
|
||||
# Save combined audio to cache directory (persistent)
|
||||
# Create a hash of sample IDs to identify this specific combination
|
||||
import hashlib
|
||||
sample_ids_str = "-".join(sorted([s.id for s in samples]))
|
||||
combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12]
|
||||
|
||||
# Store in cache directory
|
||||
cache_dir = _get_cache_dir()
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav"
|
||||
|
||||
# Save combined audio
|
||||
save_audio(combined_audio, str(combined_path), 24000)
|
||||
|
||||
try:
|
||||
# Create prompt from combined audio
|
||||
voice_prompt, _ = await tts_model.create_voice_prompt(
|
||||
tmp_path,
|
||||
combined_text,
|
||||
use_cache=use_cache,
|
||||
)
|
||||
return voice_prompt
|
||||
finally:
|
||||
# Clean up temp file
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
# Create prompt from combined audio
|
||||
voice_prompt, _ = await tts_model.create_voice_prompt(
|
||||
str(combined_path),
|
||||
combined_text,
|
||||
use_cache=use_cache,
|
||||
)
|
||||
return voice_prompt
|
||||
|
||||
|
||||
async def upload_avatar(
|
||||
|
||||
@@ -9,15 +9,38 @@ 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
|
||||
|
||||
# Chatterbox TTS sub-dependencies (chatterbox-tts itself is installed
|
||||
# --no-deps in the setup script because it pins numpy<1.26 / torch==2.6
|
||||
# which are incompatible with Python 3.12+)
|
||||
conformer>=0.3.2
|
||||
diffusers>=0.29.0
|
||||
omegaconf
|
||||
pykakasi
|
||||
resemble-perth>=1.0.1
|
||||
s3tokenizer
|
||||
spacy-pkuseg
|
||||
pyloudnorm
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Backend Tests
|
||||
|
||||
Manual test scripts for debugging and validating backend functionality.
|
||||
|
||||
## Test Files
|
||||
|
||||
### `test_generation_progress.py`
|
||||
Tests TTS generation with SSE progress monitoring to identify UX issues where users see download progress even when the model is already cached.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend
|
||||
python tests/test_generation_progress.py
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Server must be running (`python main.py`)
|
||||
- At least one voice profile must exist
|
||||
|
||||
### `test_real_download.py`
|
||||
Tests real model download with SSE progress monitoring.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend
|
||||
# Delete cache first to force fresh download
|
||||
rm -rf ~/.cache/huggingface/hub/models--openai--whisper-base
|
||||
python tests/test_real_download.py
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Server must be running (`python main.py`)
|
||||
|
||||
### `test_progress.py`
|
||||
Unit tests for ProgressManager and HFProgressTracker functionality.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend
|
||||
python tests/test_progress.py
|
||||
```
|
||||
|
||||
### `test_check_progress_state.py`
|
||||
Debugging script to inspect the internal state of ProgressManager and TaskManager.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend
|
||||
python tests/test_check_progress_state.py
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
These are manual test scripts, not automated unit tests. They're designed for:
|
||||
- Debugging progress tracking issues
|
||||
- Validating SSE event streams
|
||||
- Monitoring real-time download behavior
|
||||
- Inspecting internal state during development
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Test suite for Voicebox backend.
|
||||
|
||||
This directory contains manual test scripts for debugging and validating
|
||||
progress tracking, model downloads, and generation functionality.
|
||||
"""
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
Test TTS generation with SSE progress monitoring.
|
||||
This test captures the exact SSE events triggered during generation
|
||||
to identify UX issues where users see download progress even when
|
||||
the model is already cached.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 120):
|
||||
"""Monitor SSE stream for a model during generation."""
|
||||
events: List[Dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
|
||||
print(f"[{_timestamp()}] Connecting to SSE endpoint: {url}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
print(f"[{_timestamp()}] SSE connected, status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"[{_timestamp()}] Error: SSE endpoint returned {response.status_code}")
|
||||
return events
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
timestamp = _timestamp()
|
||||
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
print(f"[{timestamp}] → SSE Event: {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
|
||||
events.append({
|
||||
**data,
|
||||
"_timestamp": timestamp
|
||||
})
|
||||
|
||||
# Stop if complete or error
|
||||
if data.get("status") in ("complete", "error"):
|
||||
print(f"[{timestamp}] → Model {data['status']}!")
|
||||
break
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[{timestamp}] Error parsing JSON: {e}")
|
||||
print(f" Line was: {line}")
|
||||
|
||||
elif line.startswith(": heartbeat"):
|
||||
print(f"[{timestamp}] ♥ heartbeat")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
print(f"[{_timestamp()}] SSE monitoring timed out")
|
||||
except Exception as e:
|
||||
print(f"[{_timestamp()}] SSE error: {e}")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def trigger_generation(profile_id: str, text: str, model_size: str = "1.7B"):
|
||||
"""Trigger TTS generation via the API."""
|
||||
url = "http://localhost:8000/generate"
|
||||
|
||||
print(f"\n[{_timestamp()}] Triggering generation...")
|
||||
print(f" Profile: {profile_id}")
|
||||
print(f" Text: {text[:50]}...")
|
||||
print(f" Model: {model_size}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(url, json={
|
||||
"profile_id": profile_id,
|
||||
"text": text,
|
||||
"language": "en",
|
||||
"model_size": model_size,
|
||||
})
|
||||
|
||||
print(f"[{_timestamp()}] Response: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print(f"[{_timestamp()}] ✓ Generation successful!")
|
||||
print(f" Generation ID: {result.get('id')}")
|
||||
print(f" Duration: {result.get('duration', 0):.2f}s")
|
||||
return True, result
|
||||
elif response.status_code == 202:
|
||||
# Model is being downloaded
|
||||
result = response.json()
|
||||
print(f"[{_timestamp()}] → Model download in progress")
|
||||
print(f" Detail: {result}")
|
||||
return False, result
|
||||
else:
|
||||
print(f"[{_timestamp()}] ✗ Error: {response.text}")
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[{_timestamp()}] ✗ Exception: {e}")
|
||||
return False, None
|
||||
|
||||
|
||||
async def get_first_profile():
|
||||
"""Get the first available voice profile."""
|
||||
url = "http://localhost:8000/profiles"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(url)
|
||||
if response.status_code == 200:
|
||||
profiles = response.json()
|
||||
if profiles:
|
||||
return profiles[0]["id"]
|
||||
except Exception as e:
|
||||
print(f"Error getting profiles: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def check_server():
|
||||
"""Check if the server is running."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get("http://localhost:8000/health")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
print(f"Server not running: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _timestamp():
|
||||
"""Get current timestamp for logging."""
|
||||
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
|
||||
|
||||
async def test_generation_with_cached_model():
|
||||
"""
|
||||
Test Case 1: Generation when model is already cached.
|
||||
|
||||
This should NOT show any download progress events.
|
||||
If it does, that's the UX bug we're trying to fix.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("TEST CASE 1: Generation with Cached Model")
|
||||
print("=" * 80)
|
||||
print("Expected: No download progress events (or minimal/instant completion)")
|
||||
print("Actual UX Issue: Users see 'started' and 'finished' events even for cached models")
|
||||
print("=" * 80)
|
||||
|
||||
model_size = "1.7B"
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
# Get a profile
|
||||
profile_id = await get_first_profile()
|
||||
if not profile_id:
|
||||
print("✗ No voice profiles found. Please create a profile first.")
|
||||
return False
|
||||
|
||||
print(f"\nUsing profile: {profile_id}")
|
||||
|
||||
# Start SSE monitor BEFORE triggering generation
|
||||
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=30))
|
||||
|
||||
# Wait for SSE to connect
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Trigger generation
|
||||
test_text = "Hello, this is a test of the voice generation system."
|
||||
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||
|
||||
if not success and result and result.get("downloading"):
|
||||
print("\n⚠ Model is being downloaded. Waiting for download to complete...")
|
||||
# Wait for SSE monitor to capture download events
|
||||
events = await monitor_task
|
||||
return events
|
||||
|
||||
# Wait a bit more to catch any progress events
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Cancel SSE monitor
|
||||
monitor_task.cancel()
|
||||
try:
|
||||
events = await monitor_task
|
||||
except asyncio.CancelledError:
|
||||
events = []
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def test_generation_with_fresh_download():
|
||||
"""
|
||||
Test Case 2: Generation when model needs to be downloaded.
|
||||
|
||||
This SHOULD show download progress events.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("TEST CASE 2: Generation with Model Download")
|
||||
print("=" * 80)
|
||||
print("Expected: Download progress events from 0% to 100%")
|
||||
print("=" * 80)
|
||||
|
||||
# Use a different model size to force download
|
||||
model_size = "0.6B" # Smaller model for faster testing
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
# Get a profile
|
||||
profile_id = await get_first_profile()
|
||||
if not profile_id:
|
||||
print("✗ No voice profiles found. Please create a profile first.")
|
||||
return False
|
||||
|
||||
print(f"\nUsing profile: {profile_id}")
|
||||
print("Note: This will download the model if not cached")
|
||||
|
||||
# Start SSE monitor BEFORE triggering generation
|
||||
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=300))
|
||||
|
||||
# Wait for SSE to connect
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Trigger generation
|
||||
test_text = "This should trigger a model download if the model is not cached."
|
||||
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||
|
||||
if not success and result and result.get("downloading"):
|
||||
print("\n→ Model download initiated. Monitoring progress...")
|
||||
# Wait for download to complete
|
||||
events = await monitor_task
|
||||
|
||||
# Try generation again
|
||||
print(f"\n[{_timestamp()}] Retrying generation after download...")
|
||||
await asyncio.sleep(2)
|
||||
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||
|
||||
if success:
|
||||
print("✓ Generation successful after download")
|
||||
|
||||
return events
|
||||
|
||||
# If model was already cached
|
||||
await asyncio.sleep(3)
|
||||
monitor_task.cancel()
|
||||
try:
|
||||
events = await monitor_task
|
||||
except asyncio.CancelledError:
|
||||
events = []
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 80)
|
||||
print("TTS Generation Progress Test")
|
||||
print("=" * 80)
|
||||
print("Purpose: Capture exact SSE events during generation to identify UX issues")
|
||||
print("=" * 80)
|
||||
|
||||
# Check if server is running
|
||||
print(f"\n[{_timestamp()}] Checking if server is running...")
|
||||
if not await check_server():
|
||||
print("✗ Server is not running on http://localhost:8000")
|
||||
print("\nPlease start the server first:")
|
||||
print(" cd backend && python main.py")
|
||||
return False
|
||||
|
||||
print("✓ Server is running")
|
||||
|
||||
# Test Case 1: Cached model
|
||||
print("\n" + "🧪 " * 20)
|
||||
events_cached = await test_generation_with_cached_model()
|
||||
|
||||
# Results for Test Case 1
|
||||
print("\n" + "=" * 80)
|
||||
print("TEST CASE 1 RESULTS: Generation with Cached Model")
|
||||
print("=" * 80)
|
||||
|
||||
if not events_cached:
|
||||
print("✓ GOOD: No SSE progress events received")
|
||||
print(" This is the expected behavior for a cached model.")
|
||||
else:
|
||||
print(f"⚠ ISSUE FOUND: Received {len(events_cached)} SSE events:")
|
||||
print("\nEvent Timeline:")
|
||||
for i, event in enumerate(events_cached, 1):
|
||||
timestamp = event.pop("_timestamp", "??:??:??.???")
|
||||
print(f" {i}. [{timestamp}] {event}")
|
||||
|
||||
print("\n⚠ This explains the UX issue!")
|
||||
print(" Users see progress events even when the model is already cached,")
|
||||
print(" making them think the model is downloading again.")
|
||||
|
||||
# Test Case 2: Fresh download (optional, commented out by default)
|
||||
# Uncomment if you want to test download progress
|
||||
# print("\n" + "🧪 " * 20)
|
||||
# events_download = await test_generation_with_fresh_download()
|
||||
#
|
||||
# print("\n" + "=" * 80)
|
||||
# print("TEST CASE 2 RESULTS: Generation with Model Download")
|
||||
# print("=" * 80)
|
||||
#
|
||||
# if not events_download:
|
||||
# print("ℹ Model was already cached, no download occurred")
|
||||
# else:
|
||||
# print(f"✓ Received {len(events_download)} download progress events")
|
||||
# print("\nDownload Timeline:")
|
||||
# for i, event in enumerate(events_download, 1):
|
||||
# timestamp = event.pop("_timestamp", "??:??:??.???")
|
||||
# print(f" {i}. [{timestamp}] {event}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("Test Complete!")
|
||||
print("=" * 80)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Tests for profile duplicate name validation.
|
||||
|
||||
This test suite verifies that the application correctly handles
|
||||
duplicate profile names and provides user-friendly error messages.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Add parent directory to path to import backend modules
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from database import Base, VoiceProfile as DBVoiceProfile
|
||||
from models import VoiceProfileCreate
|
||||
from profiles import create_profile, update_profile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_db():
|
||||
"""Create a temporary test database."""
|
||||
# Create temporary directory for test database
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
db_path = Path(temp_dir) / "test.db"
|
||||
|
||||
# Create engine and session
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
yield db
|
||||
|
||||
# Cleanup
|
||||
db.close()
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_profiles_dir(monkeypatch, tmp_path):
|
||||
"""Mock the profiles directory to use a temporary path."""
|
||||
import profiles
|
||||
monkeypatch.setattr(profiles, '_get_profiles_dir', lambda: tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_profile_duplicate_name_raises_error(test_db, mock_profiles_dir):
|
||||
"""Test that creating a profile with a duplicate name raises a ValueError."""
|
||||
# Create first profile
|
||||
profile_data_1 = VoiceProfileCreate(
|
||||
name="Test Profile",
|
||||
description="First profile",
|
||||
language="en"
|
||||
)
|
||||
|
||||
profile_1 = await create_profile(profile_data_1, test_db)
|
||||
assert profile_1.name == "Test Profile"
|
||||
|
||||
# Try to create second profile with same name
|
||||
profile_data_2 = VoiceProfileCreate(
|
||||
name="Test Profile",
|
||||
description="Second profile",
|
||||
language="en"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await create_profile(profile_data_2, test_db)
|
||||
|
||||
# Verify error message is user-friendly
|
||||
assert "already exists" in str(exc_info.value)
|
||||
assert "Test Profile" in str(exc_info.value)
|
||||
assert "choose a different name" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_profile_different_names_succeeds(test_db, mock_profiles_dir):
|
||||
"""Test that creating profiles with different names succeeds."""
|
||||
# Create first profile
|
||||
profile_data_1 = VoiceProfileCreate(
|
||||
name="Profile One",
|
||||
description="First profile",
|
||||
language="en"
|
||||
)
|
||||
|
||||
profile_1 = await create_profile(profile_data_1, test_db)
|
||||
assert profile_1.name == "Profile One"
|
||||
|
||||
# Create second profile with different name
|
||||
profile_data_2 = VoiceProfileCreate(
|
||||
name="Profile Two",
|
||||
description="Second profile",
|
||||
language="en"
|
||||
)
|
||||
|
||||
profile_2 = await create_profile(profile_data_2, test_db)
|
||||
assert profile_2.name == "Profile Two"
|
||||
|
||||
# Verify both profiles exist
|
||||
assert profile_1.id != profile_2.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_profile_to_duplicate_name_raises_error(test_db, mock_profiles_dir):
|
||||
"""Test that updating a profile to a duplicate name raises a ValueError."""
|
||||
# Create two profiles with different names
|
||||
profile_data_1 = VoiceProfileCreate(
|
||||
name="Profile A",
|
||||
description="First profile",
|
||||
language="en"
|
||||
)
|
||||
profile_1 = await create_profile(profile_data_1, test_db)
|
||||
|
||||
profile_data_2 = VoiceProfileCreate(
|
||||
name="Profile B",
|
||||
description="Second profile",
|
||||
language="en"
|
||||
)
|
||||
profile_2 = await create_profile(profile_data_2, test_db)
|
||||
|
||||
# Try to update profile_2 to use profile_1's name
|
||||
update_data = VoiceProfileCreate(
|
||||
name="Profile A", # Duplicate name
|
||||
description="Updated description",
|
||||
language="en"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await update_profile(profile_2.id, update_data, test_db)
|
||||
|
||||
# Verify error message is user-friendly
|
||||
assert "already exists" in str(exc_info.value)
|
||||
assert "Profile A" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_profile_keep_same_name_succeeds(test_db, mock_profiles_dir):
|
||||
"""Test that updating a profile while keeping the same name succeeds."""
|
||||
# Create profile
|
||||
profile_data = VoiceProfileCreate(
|
||||
name="My Profile",
|
||||
description="Original description",
|
||||
language="en"
|
||||
)
|
||||
profile = await create_profile(profile_data, test_db)
|
||||
|
||||
# Update profile with same name but different description
|
||||
update_data = VoiceProfileCreate(
|
||||
name="My Profile", # Same name
|
||||
description="Updated description",
|
||||
language="en"
|
||||
)
|
||||
|
||||
updated_profile = await update_profile(profile.id, update_data, test_db)
|
||||
|
||||
# Verify update succeeded
|
||||
assert updated_profile is not None
|
||||
assert updated_profile.id == profile.id
|
||||
assert updated_profile.name == "My Profile"
|
||||
assert updated_profile.description == "Updated description"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_profile_to_new_unique_name_succeeds(test_db, mock_profiles_dir):
|
||||
"""Test that updating a profile to a new unique name succeeds."""
|
||||
# Create profile
|
||||
profile_data = VoiceProfileCreate(
|
||||
name="Original Name",
|
||||
description="Profile description",
|
||||
language="en"
|
||||
)
|
||||
profile = await create_profile(profile_data, test_db)
|
||||
|
||||
# Update profile with new unique name
|
||||
update_data = VoiceProfileCreate(
|
||||
name="New Unique Name",
|
||||
description="Updated description",
|
||||
language="en"
|
||||
)
|
||||
|
||||
updated_profile = await update_profile(profile.id, update_data, test_db)
|
||||
|
||||
# Verify update succeeded
|
||||
assert updated_profile is not None
|
||||
assert updated_profile.id == profile.id
|
||||
assert updated_profile.name == "New Unique Name"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_case_sensitive_names_allowed(test_db, mock_profiles_dir):
|
||||
"""Test that profile names are case-sensitive (e.g., 'Test' and 'test' are different)."""
|
||||
# Create profile with lowercase name
|
||||
profile_data_1 = VoiceProfileCreate(
|
||||
name="test profile",
|
||||
description="Lowercase",
|
||||
language="en"
|
||||
)
|
||||
profile_1 = await create_profile(profile_data_1, test_db)
|
||||
|
||||
# Create profile with different case
|
||||
profile_data_2 = VoiceProfileCreate(
|
||||
name="Test Profile",
|
||||
description="Title case",
|
||||
language="en"
|
||||
)
|
||||
profile_2 = await create_profile(profile_data_2, test_db)
|
||||
|
||||
# Both should succeed since SQLite unique constraint is case-sensitive by default
|
||||
assert profile_1.name == "test profile"
|
||||
assert profile_2.name == "Test Profile"
|
||||
assert profile_1.id != profile_2.id
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Test script to debug model download progress tracking.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import List, Dict
|
||||
import logging
|
||||
|
||||
# Set up logging to see what's happening
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
from utils.progress import ProgressManager, get_progress_manager
|
||||
from utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||
|
||||
|
||||
def test_progress_manager_basic():
|
||||
"""Test 1: Basic ProgressManager functionality."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 1: ProgressManager Basic Operations")
|
||||
print("=" * 60)
|
||||
|
||||
pm = ProgressManager()
|
||||
|
||||
# Test update_progress
|
||||
pm.update_progress(
|
||||
model_name="test-model",
|
||||
current=50,
|
||||
total=100,
|
||||
filename="test.bin",
|
||||
status="downloading"
|
||||
)
|
||||
|
||||
# Test get_progress
|
||||
progress = pm.get_progress("test-model")
|
||||
print(f"✓ Progress stored: {progress}")
|
||||
assert progress is not None
|
||||
assert progress["progress"] == 50.0
|
||||
assert progress["filename"] == "test.bin"
|
||||
assert progress["status"] == "downloading"
|
||||
|
||||
# Test mark_complete
|
||||
pm.mark_complete("test-model")
|
||||
progress = pm.get_progress("test-model")
|
||||
print(f"✓ Marked complete: {progress}")
|
||||
assert progress["status"] == "complete"
|
||||
assert progress["progress"] == 100.0
|
||||
|
||||
print("✓ Test 1 PASSED\n")
|
||||
return True
|
||||
|
||||
|
||||
async def test_progress_manager_sse():
|
||||
"""Test 2: ProgressManager SSE streaming."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 2: ProgressManager SSE Streaming")
|
||||
print("=" * 60)
|
||||
|
||||
pm = ProgressManager()
|
||||
collected_events: List[Dict] = []
|
||||
|
||||
# Simulate SSE client
|
||||
async def sse_client():
|
||||
"""Simulates a frontend SSE connection."""
|
||||
print(" SSE client: Subscribing to test-model-sse...")
|
||||
async for event in pm.subscribe("test-model-sse"):
|
||||
# Parse SSE event
|
||||
if event.startswith("data: "):
|
||||
data = json.loads(event[6:])
|
||||
print(f" SSE client: Received event: {data['status']} - {data.get('progress', 0):.1f}%")
|
||||
collected_events.append(data)
|
||||
|
||||
# Stop when complete
|
||||
if data.get("status") in ("complete", "error"):
|
||||
break
|
||||
elif event.startswith(": heartbeat"):
|
||||
print(" SSE client: Received heartbeat")
|
||||
|
||||
# Simulate download progress updates (from backend thread)
|
||||
async def simulate_download():
|
||||
"""Simulates backend sending progress updates."""
|
||||
print(" Backend: Starting simulated download...")
|
||||
await asyncio.sleep(0.2) # Let SSE client subscribe first
|
||||
|
||||
# Send progress updates
|
||||
for i in range(0, 101, 20):
|
||||
print(f" Backend: Updating progress to {i}%")
|
||||
pm.update_progress(
|
||||
model_name="test-model-sse",
|
||||
current=i,
|
||||
total=100,
|
||||
filename=f"file_{i}.bin",
|
||||
status="downloading" if i < 100 else "downloading"
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Mark complete
|
||||
print(" Backend: Marking download complete")
|
||||
pm.mark_complete("test-model-sse")
|
||||
|
||||
# Run SSE client and download simulation concurrently
|
||||
await asyncio.gather(
|
||||
sse_client(),
|
||||
simulate_download()
|
||||
)
|
||||
|
||||
# Verify we got events
|
||||
print(f"\n Collected {len(collected_events)} events")
|
||||
assert len(collected_events) > 0, "Should have received at least one event"
|
||||
assert collected_events[-1]["status"] == "complete", "Last event should be 'complete'"
|
||||
|
||||
print("✓ Test 2 PASSED\n")
|
||||
return True
|
||||
|
||||
|
||||
def test_hf_progress_tracker():
|
||||
"""Test 3: HFProgressTracker tqdm patching."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 3: HFProgressTracker tqdm Patching")
|
||||
print("=" * 60)
|
||||
|
||||
captured_progress: List[tuple] = []
|
||||
|
||||
def progress_callback(downloaded: int, total: int, filename: str):
|
||||
"""Capture progress updates."""
|
||||
captured_progress.append((downloaded, total, filename))
|
||||
print(f" Progress callback: {downloaded}/{total} bytes ({filename})")
|
||||
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
|
||||
# Simulate a download with tqdm
|
||||
with tracker.patch_download():
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
|
||||
# Simulate downloading a file
|
||||
print(" Simulating download with tqdm...")
|
||||
total_size = 1000
|
||||
with tqdm(total=total_size, desc="model.bin", unit="B", unit_scale=True) as pbar:
|
||||
for chunk in range(0, total_size, 100):
|
||||
pbar.update(100)
|
||||
time.sleep(0.01)
|
||||
|
||||
print(f" Captured {len(captured_progress)} progress updates")
|
||||
assert len(captured_progress) > 0, "Should have captured progress updates"
|
||||
|
||||
# Verify progress increases
|
||||
last_downloaded = 0
|
||||
for downloaded, total, filename in captured_progress:
|
||||
assert downloaded >= last_downloaded, "Downloaded bytes should increase"
|
||||
assert total == total_size, "Total should be consistent"
|
||||
last_downloaded = downloaded
|
||||
|
||||
print("✓ Test 3 PASSED\n")
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
print("✗ tqdm not available, skipping test\n")
|
||||
return None
|
||||
|
||||
|
||||
async def test_full_integration():
|
||||
"""Test 4: Full integration test."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 4: Full Integration (ProgressManager + HFProgressTracker)")
|
||||
print("=" * 60)
|
||||
|
||||
pm = get_progress_manager()
|
||||
collected_events: List[Dict] = []
|
||||
|
||||
# SSE client
|
||||
async def sse_client():
|
||||
print(" SSE client: Subscribing...")
|
||||
async for event in pm.subscribe("integration-test"):
|
||||
if event.startswith("data: "):
|
||||
data = json.loads(event[6:])
|
||||
print(f" SSE client: {data['status']} - {data.get('progress', 0):.1f}% - {data.get('filename', '')}")
|
||||
collected_events.append(data)
|
||||
if data.get("status") in ("complete", "error"):
|
||||
break
|
||||
|
||||
# Simulate backend download with HFProgressTracker
|
||||
async def simulate_real_download():
|
||||
await asyncio.sleep(0.2) # Let SSE subscribe
|
||||
|
||||
print(" Backend: Starting download with HFProgressTracker...")
|
||||
|
||||
# Set up tracking (like the real backend does)
|
||||
progress_callback = create_hf_progress_callback("integration-test", pm)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
|
||||
# Initialize progress
|
||||
pm.update_progress(
|
||||
model_name="integration-test",
|
||||
current=0,
|
||||
total=1,
|
||||
filename="",
|
||||
status="downloading"
|
||||
)
|
||||
|
||||
# Simulate download with tqdm patching
|
||||
with tracker.patch_download():
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
|
||||
# Simulate multi-file download (like HuggingFace does)
|
||||
files = [
|
||||
("model.safetensors", 5000),
|
||||
("config.json", 1000),
|
||||
("tokenizer.json", 500),
|
||||
]
|
||||
|
||||
for filename, size in files:
|
||||
print(f" Backend: Downloading {filename}...")
|
||||
with tqdm(total=size, desc=filename, unit="B") as pbar:
|
||||
for chunk in range(0, size, 500):
|
||||
chunk_size = min(500, size - chunk)
|
||||
pbar.update(chunk_size)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# Mark complete
|
||||
print(" Backend: Download complete")
|
||||
pm.mark_complete("integration-test")
|
||||
|
||||
except ImportError:
|
||||
print(" ✗ tqdm not available")
|
||||
pm.mark_error("integration-test", "tqdm not available")
|
||||
|
||||
# Run both
|
||||
await asyncio.gather(
|
||||
sse_client(),
|
||||
simulate_real_download()
|
||||
)
|
||||
|
||||
# Verify
|
||||
print(f"\n Collected {len(collected_events)} events")
|
||||
if len(collected_events) > 0:
|
||||
print(f" First event: {collected_events[0]}")
|
||||
print(f" Last event: {collected_events[-1]}")
|
||||
assert collected_events[-1]["status"] == "complete", "Should end with 'complete'"
|
||||
print("✓ Test 4 PASSED\n")
|
||||
return True
|
||||
else:
|
||||
print("✗ Test 4 FAILED - No events received\n")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all tests."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Voicebox Progress Tracking Test Suite")
|
||||
print("=" * 60)
|
||||
|
||||
results = []
|
||||
|
||||
# Test 1: Basic operations
|
||||
try:
|
||||
results.append(("Basic Operations", test_progress_manager_basic()))
|
||||
except Exception as e:
|
||||
print(f"✗ Test 1 FAILED: {e}\n")
|
||||
results.append(("Basic Operations", False))
|
||||
|
||||
# Test 2: SSE streaming
|
||||
try:
|
||||
results.append(("SSE Streaming", await test_progress_manager_sse()))
|
||||
except Exception as e:
|
||||
print(f"✗ Test 2 FAILED: {e}\n")
|
||||
results.append(("SSE Streaming", False))
|
||||
|
||||
# Test 3: tqdm patching
|
||||
try:
|
||||
results.append(("tqdm Patching", test_hf_progress_tracker()))
|
||||
except Exception as e:
|
||||
print(f"✗ Test 3 FAILED: {e}\n")
|
||||
results.append(("tqdm Patching", False))
|
||||
|
||||
# Test 4: Full integration
|
||||
try:
|
||||
results.append(("Full Integration", await test_full_integration()))
|
||||
except Exception as e:
|
||||
print(f"✗ Test 4 FAILED: {e}\n")
|
||||
results.append(("Full Integration", False))
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("Test Results Summary")
|
||||
print("=" * 60)
|
||||
|
||||
for name, result in results:
|
||||
status = "✓ PASS" if result else ("⊘ SKIP" if result is None else "✗ FAIL")
|
||||
print(f" {status:8} {name}")
|
||||
|
||||
passed = sum(1 for _, r in results if r is True)
|
||||
failed = sum(1 for _, r in results if r is False)
|
||||
skipped = sum(1 for _, r in results if r is None)
|
||||
|
||||
print()
|
||||
print(f" Total: {len(results)} tests")
|
||||
print(f" Passed: {passed}")
|
||||
print(f" Failed: {failed}")
|
||||
print(f" Skipped: {skipped}")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
return failed == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = asyncio.run(main())
|
||||
exit(0 if success else 1)
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Test Qwen TTS model download with SSE progress monitoring.
|
||||
|
||||
This specifically tests the MLX TTS backend download progress tracking,
|
||||
which requires tqdm to be patched BEFORE mlx_audio is imported.
|
||||
|
||||
Usage:
|
||||
cd backend && python -m tests.test_qwen_download
|
||||
|
||||
Prerequisites:
|
||||
- Server must be running: cd backend && python main.py
|
||||
- Delete model first for fresh download test:
|
||||
curl -X DELETE http://localhost:8000/models/qwen-tts-0.6B
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
import time
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 600) -> List[Dict]:
|
||||
"""
|
||||
Monitor SSE stream for a model download.
|
||||
|
||||
Args:
|
||||
model_name: Name of the model to monitor
|
||||
timeout: Maximum time to wait for download (seconds)
|
||||
|
||||
Returns:
|
||||
List of SSE events received
|
||||
"""
|
||||
events: List[Dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
last_progress = -1
|
||||
|
||||
print(f"\n📡 Connecting to SSE endpoint: {url}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
print(f" SSE connected, status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f" ❌ Error: SSE endpoint returned {response.status_code}")
|
||||
return events
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
events.append(data)
|
||||
|
||||
# Print progress (only when it changes significantly)
|
||||
progress = data.get('progress', 0)
|
||||
status = data.get('status', 'unknown')
|
||||
filename = data.get('filename', '')
|
||||
current = data.get('current', 0)
|
||||
total = data.get('total', 0)
|
||||
|
||||
# Print every 5% change or status change
|
||||
if abs(progress - last_progress) >= 5 or status in ('complete', 'error'):
|
||||
current_mb = current / (1024 * 1024)
|
||||
total_mb = total / (1024 * 1024)
|
||||
print(f" 📊 {status:12} {progress:6.1f}% ({current_mb:.1f}MB / {total_mb:.1f}MB) {filename[:50]}")
|
||||
last_progress = progress
|
||||
|
||||
# Stop if complete or error
|
||||
if status in ("complete", "error"):
|
||||
if status == "complete":
|
||||
print(f" ✅ Download complete!")
|
||||
else:
|
||||
print(f" ❌ Download error: {data.get('error', 'unknown')}")
|
||||
break
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" ⚠️ Error parsing JSON: {e}")
|
||||
|
||||
elif line.startswith(": heartbeat"):
|
||||
# Heartbeat every 1 second, don't spam
|
||||
pass
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print(" ⏹️ SSE monitor cancelled")
|
||||
except Exception as e:
|
||||
print(f" ❌ SSE error: {e}")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def trigger_download(model_name: str) -> bool:
|
||||
"""Trigger a model download via the API."""
|
||||
url = "http://localhost:8000/models/download"
|
||||
|
||||
print(f"\n🚀 Triggering download for: {model_name}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(url, json={"model_name": model_name})
|
||||
result = response.json()
|
||||
print(f" Response: {response.status_code} - {result}")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
print(f" ❌ Error triggering download: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def delete_model(model_name: str) -> bool:
|
||||
"""Delete a model from cache."""
|
||||
url = f"http://localhost:8000/models/{model_name}"
|
||||
|
||||
print(f"\n🗑️ Deleting model: {model_name}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.delete(url)
|
||||
if response.status_code == 200:
|
||||
print(f" ✅ Model deleted")
|
||||
return True
|
||||
elif response.status_code == 404:
|
||||
print(f" ℹ️ Model not found (already deleted)")
|
||||
return True
|
||||
else:
|
||||
print(f" ⚠️ Delete response: {response.status_code} - {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ❌ Error deleting model: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def check_model_status(model_name: str) -> Optional[Dict]:
|
||||
"""Check the status of a model."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get("http://localhost:8000/models/status")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
for model in data.get("models", []):
|
||||
if model["model_name"] == model_name:
|
||||
return model
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Error checking model status: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def check_server() -> bool:
|
||||
"""Check if the server is running."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get("http://localhost:8000/health")
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 70)
|
||||
print("🧪 Qwen TTS Model Download Progress Test")
|
||||
print("=" * 70)
|
||||
print("\nThis test verifies that MLX TTS download progress tracking works.")
|
||||
print("It specifically tests the tqdm patching for mlx_audio.tts imports.")
|
||||
|
||||
# Check if server is running
|
||||
print("\n📡 Checking if server is running...")
|
||||
if not await check_server():
|
||||
print(" ❌ Server is not running on http://localhost:8000")
|
||||
print("\n Please start the server first:")
|
||||
print(" cd backend && python main.py")
|
||||
return False
|
||||
|
||||
print(" ✅ Server is running")
|
||||
|
||||
# Test model
|
||||
model_name = "qwen-tts-0.6B" # Note: 0.6B currently maps to 1.7B on MLX
|
||||
|
||||
# Check current status
|
||||
print(f"\n📊 Checking status of {model_name}...")
|
||||
status = await check_model_status(model_name)
|
||||
if status:
|
||||
print(f" Downloaded: {status.get('downloaded', False)}")
|
||||
print(f" Downloading: {status.get('downloading', False)}")
|
||||
print(f" Loaded: {status.get('loaded', False)}")
|
||||
if status.get('size_mb'):
|
||||
print(f" Size: {status['size_mb']:.1f} MB")
|
||||
else:
|
||||
print(" ⚠️ Could not get model status")
|
||||
|
||||
# Ask if user wants to delete first
|
||||
print("\n" + "-" * 70)
|
||||
if status and status.get('downloaded'):
|
||||
print("⚠️ Model is already downloaded. Delete it for a fresh download test?")
|
||||
print(" [y] Yes, delete and download fresh")
|
||||
print(" [n] No, just test SSE connection")
|
||||
print(" [q] Quit")
|
||||
|
||||
choice = input("\nChoice [y/n/q]: ").strip().lower()
|
||||
|
||||
if choice == 'q':
|
||||
print("Exiting...")
|
||||
return True
|
||||
|
||||
if choice == 'y':
|
||||
if not await delete_model(model_name):
|
||||
print("Failed to delete model. Continue anyway? [y/n]")
|
||||
if input().strip().lower() != 'y':
|
||||
return False
|
||||
else:
|
||||
print("Model not downloaded. Will perform fresh download test.")
|
||||
input("Press Enter to continue...")
|
||||
|
||||
# Run the test
|
||||
print("\n" + "=" * 70)
|
||||
print("🏃 Starting Download Test")
|
||||
print("=" * 70)
|
||||
|
||||
async def run_test():
|
||||
# Start SSE monitor in background FIRST
|
||||
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=600))
|
||||
|
||||
# Wait for SSE to connect
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Trigger download
|
||||
success = await trigger_download(model_name)
|
||||
|
||||
if not success:
|
||||
print(" ❌ Failed to trigger download")
|
||||
monitor_task.cancel()
|
||||
try:
|
||||
await monitor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return []
|
||||
|
||||
# Wait for SSE monitor to complete
|
||||
print("\n⏳ Waiting for download to complete (this may take several minutes)...")
|
||||
events = await monitor_task
|
||||
|
||||
return events
|
||||
|
||||
start_time = time.time()
|
||||
events = await run_test()
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Results
|
||||
print("\n" + "=" * 70)
|
||||
print("📋 Test Results")
|
||||
print("=" * 70)
|
||||
|
||||
print(f"\n⏱️ Elapsed time: {elapsed:.1f} seconds")
|
||||
print(f"📨 Total SSE events received: {len(events)}")
|
||||
|
||||
if not events:
|
||||
print("\n❌ FAILED - No SSE events received!")
|
||||
print("\nPossible causes:")
|
||||
print(" 1. SSE endpoint not working")
|
||||
print(" 2. tqdm not patched before mlx_audio import")
|
||||
print(" 3. Progress callbacks not firing")
|
||||
print(" 4. Model already fully downloaded")
|
||||
print("\nDebug steps:")
|
||||
print(" 1. Check server logs for [DEBUG] messages")
|
||||
print(" 2. Look for 'tqdm patched' before 'mlx_audio.tts import'")
|
||||
print(f" 3. Delete model: curl -X DELETE http://localhost:8000/models/{model_name}")
|
||||
return False
|
||||
|
||||
# Analyze events
|
||||
first_event = events[0]
|
||||
last_event = events[-1]
|
||||
|
||||
print(f"\n📊 First event:")
|
||||
print(f" Status: {first_event.get('status')}")
|
||||
print(f" Progress: {first_event.get('progress', 0):.1f}%")
|
||||
|
||||
print(f"\n📊 Last event:")
|
||||
print(f" Status: {last_event.get('status')}")
|
||||
print(f" Progress: {last_event.get('progress', 0):.1f}%")
|
||||
|
||||
# Check for expected behaviors
|
||||
has_progress_updates = len(events) > 2
|
||||
has_increasing_progress = False
|
||||
has_complete = any(e.get('status') == 'complete' for e in events)
|
||||
has_100_percent = any(e.get('progress', 0) >= 100 for e in events)
|
||||
|
||||
# Check if progress increased over time
|
||||
if len(events) >= 2:
|
||||
progress_values = [e.get('progress', 0) for e in events]
|
||||
has_increasing_progress = progress_values[-1] > progress_values[0]
|
||||
|
||||
print("\n📋 Checks:")
|
||||
print(f" {'✅' if has_progress_updates else '❌'} Multiple progress updates received ({len(events)} events)")
|
||||
print(f" {'✅' if has_increasing_progress else '❌'} Progress increased over time")
|
||||
print(f" {'✅' if has_100_percent else '❌'} Reached 100% progress")
|
||||
print(f" {'✅' if has_complete else '❌'} Received 'complete' status")
|
||||
|
||||
# Overall result
|
||||
success = has_progress_updates and has_complete
|
||||
|
||||
if success:
|
||||
print("\n" + "=" * 70)
|
||||
print("✅ TEST PASSED - Qwen TTS download progress tracking works!")
|
||||
print("=" * 70)
|
||||
else:
|
||||
print("\n" + "=" * 70)
|
||||
print("❌ TEST FAILED - Progress tracking has issues")
|
||||
print("=" * 70)
|
||||
print("\nCheck the server logs for debug output.")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = asyncio.run(main())
|
||||
exit(0 if result else 1)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Test real model download with SSE progress monitoring.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
import time
|
||||
from typing import List, Dict
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 300):
|
||||
"""Monitor SSE stream for a model download."""
|
||||
events: List[Dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
|
||||
print(f"Connecting to SSE endpoint: {url}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
print(f"SSE connected, status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"Error: SSE endpoint returned {response.status_code}")
|
||||
return events
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
print(f" Raw SSE: {line[:100]}...") # Print first 100 chars
|
||||
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
print(f" → {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
|
||||
events.append(data)
|
||||
|
||||
# Stop if complete or error
|
||||
if data.get("status") in ("complete", "error"):
|
||||
print(f" Download {data['status']}!")
|
||||
break
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" Error parsing JSON: {e}")
|
||||
print(f" Line was: {line}")
|
||||
|
||||
elif line.startswith(": heartbeat"):
|
||||
print(" ♥ heartbeat")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def trigger_download(model_name: str):
|
||||
"""Trigger a model download via the API."""
|
||||
url = "http://localhost:8000/models/download"
|
||||
|
||||
print(f"\nTriggering download for: {model_name}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=300) as client:
|
||||
response = await client.post(url, json={"model_name": model_name})
|
||||
print(f"Response: {response.status_code} - {response.json()}")
|
||||
return response.status_code == 200
|
||||
|
||||
|
||||
async def check_server():
|
||||
"""Check if the server is running."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get("http://localhost:8000/health")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
print(f"Server not running: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 60)
|
||||
print("Real Model Download Progress Test")
|
||||
print("=" * 60)
|
||||
|
||||
# Check if server is running
|
||||
print("\nChecking if server is running...")
|
||||
if not await check_server():
|
||||
print("✗ Server is not running on http://localhost:8000")
|
||||
print("\nPlease start the server first:")
|
||||
print(" cd backend && python main.py")
|
||||
return False
|
||||
|
||||
print("✓ Server is running")
|
||||
|
||||
# Choose a small model for testing
|
||||
model_name = "whisper-base" # ~150MB, faster to download
|
||||
print(f"\nUsing model: {model_name}")
|
||||
|
||||
# Option to delete model first if it exists
|
||||
print("\nDo you want to delete the model first to force a fresh download? (y/n)")
|
||||
# For automated testing, skip deletion prompt
|
||||
# delete_first = input().strip().lower() == 'y'
|
||||
delete_first = False
|
||||
|
||||
if delete_first:
|
||||
print(f"Deleting {model_name}...")
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.delete(f"http://localhost:8000/models/{model_name}")
|
||||
print(f"Delete response: {response.status_code}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Starting Test")
|
||||
print("=" * 60)
|
||||
|
||||
# Start monitoring SSE stream BEFORE triggering download
|
||||
async def run_test():
|
||||
# Start SSE monitor in background
|
||||
monitor_task = asyncio.create_task(monitor_sse_stream(model_name))
|
||||
|
||||
# Wait a bit to ensure SSE is connected
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Trigger download
|
||||
success = await trigger_download(model_name)
|
||||
|
||||
if not success:
|
||||
print("✗ Failed to trigger download")
|
||||
monitor_task.cancel()
|
||||
return False
|
||||
|
||||
# Wait for SSE monitor to complete
|
||||
events = await monitor_task
|
||||
|
||||
return events
|
||||
|
||||
events = await run_test()
|
||||
|
||||
# Results
|
||||
print("\n" + "=" * 60)
|
||||
print("Test Results")
|
||||
print("=" * 60)
|
||||
|
||||
if not events:
|
||||
print("✗ FAILED - No SSE events received!")
|
||||
print("\nPossible causes:")
|
||||
print(" 1. SSE endpoint not working")
|
||||
print(" 2. Progress updates not being sent")
|
||||
print(" 3. Model already downloaded (no progress to report)")
|
||||
print("\nTry deleting the model first to force a fresh download:")
|
||||
print(f" curl -X DELETE http://localhost:8000/models/{model_name}")
|
||||
return False
|
||||
|
||||
print(f"✓ Received {len(events)} SSE events")
|
||||
print(f"\nFirst event: {events[0]}")
|
||||
print(f"Last event: {events[-1]}")
|
||||
|
||||
# Check if we got meaningful progress
|
||||
has_progress = any(e.get('progress', 0) > 0 for e in events)
|
||||
has_complete = any(e.get('status') == 'complete' for e in events)
|
||||
|
||||
if has_progress:
|
||||
print("✓ Progress updates received")
|
||||
else:
|
||||
print("✗ No progress updates (might be already downloaded)")
|
||||
|
||||
if has_complete:
|
||||
print("✓ Download completed successfully")
|
||||
else:
|
||||
print("✗ Download did not complete")
|
||||
|
||||
success = has_progress and has_complete
|
||||
|
||||
if success:
|
||||
print("\n✓ TEST PASSED - Progress tracking works!")
|
||||
else:
|
||||
print("\n⊘ TEST INCONCLUSIVE - Try with a fresh download")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -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()
|
||||
|
||||
+121
-3
@@ -70,14 +70,132 @@ def save_audio(
|
||||
sample_rate: int = 24000,
|
||||
) -> None:
|
||||
"""
|
||||
Save audio file.
|
||||
|
||||
Save audio file with atomic write and error handling.
|
||||
|
||||
Writes to a temporary file first, then atomically renames to the
|
||||
target path. This prevents corrupted/partial WAV files if the
|
||||
process is interrupted mid-write.
|
||||
|
||||
Args:
|
||||
audio: Audio array
|
||||
path: Output path
|
||||
sample_rate: Sample rate
|
||||
|
||||
Raises:
|
||||
OSError: If file cannot be written
|
||||
"""
|
||||
sf.write(path, audio, sample_rate)
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
temp_path = f"{path}.tmp"
|
||||
try:
|
||||
# Ensure parent directory exists
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write to temporary file first
|
||||
sf.write(temp_path, audio, sample_rate)
|
||||
|
||||
# Atomic rename to final path
|
||||
os.replace(temp_path, path)
|
||||
|
||||
except Exception as e:
|
||||
# Clean up temp file on failure
|
||||
try:
|
||||
if Path(temp_path).exists():
|
||||
Path(temp_path).unlink()
|
||||
except Exception:
|
||||
pass # Best effort cleanup
|
||||
|
||||
raise OSError(f"Failed to save audio to {path}: {e}") from e
|
||||
|
||||
|
||||
def trim_tts_output(
|
||||
audio: np.ndarray,
|
||||
sample_rate: int = 24000,
|
||||
frame_ms: int = 20,
|
||||
silence_threshold_db: float = -40.0,
|
||||
min_silence_ms: int = 200,
|
||||
max_internal_silence_ms: int = 1000,
|
||||
fade_ms: int = 30,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Trim trailing silence and post-silence hallucination from TTS output.
|
||||
|
||||
Chatterbox sometimes produces ``[speech][silence][hallucinated noise]``.
|
||||
This detects internal silence gaps longer than *max_internal_silence_ms*
|
||||
and cuts the audio at that boundary, then trims trailing silence and
|
||||
applies a short cosine fade-out.
|
||||
|
||||
Args:
|
||||
audio: Input audio array (mono float32)
|
||||
sample_rate: Sample rate in Hz
|
||||
frame_ms: Frame size for RMS energy calculation
|
||||
silence_threshold_db: dB threshold below which a frame is silence
|
||||
min_silence_ms: Minimum trailing silence to keep
|
||||
max_internal_silence_ms: Cut after any silence gap longer than this
|
||||
fade_ms: Cosine fade-out duration in ms
|
||||
|
||||
Returns:
|
||||
Trimmed audio array
|
||||
"""
|
||||
frame_len = int(sample_rate * frame_ms / 1000)
|
||||
if frame_len == 0 or len(audio) < frame_len:
|
||||
return audio
|
||||
|
||||
n_frames = len(audio) // frame_len
|
||||
threshold_linear = 10 ** (silence_threshold_db / 20)
|
||||
|
||||
# Compute per-frame RMS
|
||||
rms = np.array(
|
||||
[
|
||||
np.sqrt(np.mean(audio[i * frame_len : (i + 1) * frame_len] ** 2))
|
||||
for i in range(n_frames)
|
||||
]
|
||||
)
|
||||
is_speech = rms >= threshold_linear
|
||||
|
||||
# Find first speech frame
|
||||
first_speech = 0
|
||||
for i, s in enumerate(is_speech):
|
||||
if s:
|
||||
first_speech = max(0, i - 1) # keep 1 frame padding
|
||||
break
|
||||
|
||||
# Walk forward from first speech; cut at long internal silence gaps
|
||||
max_silence_frames = int(max_internal_silence_ms / frame_ms)
|
||||
consecutive_silence = 0
|
||||
cut_frame = n_frames
|
||||
|
||||
for i in range(first_speech, n_frames):
|
||||
if is_speech[i]:
|
||||
consecutive_silence = 0
|
||||
else:
|
||||
consecutive_silence += 1
|
||||
if consecutive_silence >= max_silence_frames:
|
||||
cut_frame = i - consecutive_silence + 1
|
||||
break
|
||||
|
||||
# Trim trailing silence from the cut point
|
||||
min_silence_frames = int(min_silence_ms / frame_ms)
|
||||
end_frame = cut_frame
|
||||
while end_frame > first_speech and not is_speech[end_frame - 1]:
|
||||
end_frame -= 1
|
||||
# Keep a short tail
|
||||
end_frame = min(end_frame + min_silence_frames, cut_frame)
|
||||
|
||||
# Convert frames back to samples
|
||||
start_sample = first_speech * frame_len
|
||||
end_sample = min(end_frame * frame_len, len(audio))
|
||||
|
||||
trimmed = audio[start_sample:end_sample].copy()
|
||||
|
||||
# Cosine fade-out
|
||||
fade_samples = int(sample_rate * fade_ms / 1000)
|
||||
if fade_samples > 0 and len(trimmed) > fade_samples:
|
||||
fade = np.cos(np.linspace(0, np.pi / 2, fade_samples)) ** 2
|
||||
trimmed[-fade_samples:] *= fade
|
||||
|
||||
return trimmed
|
||||
|
||||
|
||||
def validate_reference_audio(
|
||||
|
||||
@@ -88,3 +88,63 @@ def cache_voice_prompt(
|
||||
# Store on disk (torch.save can handle both dicts and tensors)
|
||||
cache_file = _get_cache_dir() / f"{cache_key}.prompt"
|
||||
torch.save(voice_prompt, cache_file)
|
||||
|
||||
|
||||
def clear_voice_prompt_cache() -> int:
|
||||
"""
|
||||
Clear all voice prompt caches (memory and disk).
|
||||
|
||||
Returns:
|
||||
Number of cache files deleted
|
||||
"""
|
||||
# Clear memory cache
|
||||
_memory_cache.clear()
|
||||
|
||||
# Clear disk cache
|
||||
cache_dir = _get_cache_dir()
|
||||
deleted_count = 0
|
||||
|
||||
if cache_dir.exists():
|
||||
# Delete prompt cache files
|
||||
for cache_file in cache_dir.glob("*.prompt"):
|
||||
try:
|
||||
cache_file.unlink()
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"Failed to delete cache file {cache_file}: {e}")
|
||||
|
||||
# Delete combined audio files
|
||||
for audio_file in cache_dir.glob("combined_*.wav"):
|
||||
try:
|
||||
audio_file.unlink()
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"Failed to delete combined audio file {audio_file}: {e}")
|
||||
|
||||
return deleted_count
|
||||
|
||||
|
||||
def clear_profile_cache(profile_id: str) -> int:
|
||||
"""
|
||||
Clear cache files for a specific profile.
|
||||
|
||||
Args:
|
||||
profile_id: Profile ID
|
||||
|
||||
Returns:
|
||||
Number of cache files deleted
|
||||
"""
|
||||
cache_dir = _get_cache_dir()
|
||||
deleted_count = 0
|
||||
|
||||
if cache_dir.exists():
|
||||
# Delete combined audio files for this profile
|
||||
pattern = f"combined_{profile_id}_*.wav"
|
||||
for audio_file in cache_dir.glob(pattern):
|
||||
try:
|
||||
audio_file.unlink()
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"Failed to delete combined audio file {audio_file}: {e}")
|
||||
|
||||
return deleted_count
|
||||
|
||||
+159
-26
@@ -11,8 +11,9 @@ import sys
|
||||
class HFProgressTracker:
|
||||
"""Tracks HuggingFace Hub download progress by intercepting tqdm."""
|
||||
|
||||
def __init__(self, progress_callback: Optional[Callable] = None):
|
||||
def __init__(self, progress_callback: Optional[Callable] = None, filter_non_downloads: bool = False):
|
||||
self.progress_callback = progress_callback
|
||||
self.filter_non_downloads = filter_non_downloads # Only filter if True
|
||||
self._original_tqdm_class = None
|
||||
self._lock = threading.Lock()
|
||||
self._total_downloaded = 0
|
||||
@@ -21,6 +22,7 @@ class HFProgressTracker:
|
||||
self._file_downloaded = {} # Track downloaded bytes per file
|
||||
self._current_filename = ""
|
||||
self._active_tqdms = {} # Track active tqdm instances
|
||||
self._hf_tqdm_original_update = None # For monkey-patching hf's tqdm
|
||||
|
||||
def _create_tracked_tqdm_class(self):
|
||||
"""Create a tqdm subclass that tracks progress."""
|
||||
@@ -29,7 +31,7 @@ class HFProgressTracker:
|
||||
|
||||
class TrackedTqdm(original_tqdm):
|
||||
"""A tqdm subclass that reports progress to our tracker."""
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Extract filename from desc before passing to parent
|
||||
desc = kwargs.get("desc", "")
|
||||
@@ -80,7 +82,7 @@ class HFProgressTracker:
|
||||
|
||||
def update(self, n=1):
|
||||
result = super().update(n)
|
||||
|
||||
|
||||
# Report progress
|
||||
with tracker._lock:
|
||||
if id(self) in tracker._active_tqdms:
|
||||
@@ -89,6 +91,16 @@ class HFProgressTracker:
|
||||
total = getattr(self, "total", 0)
|
||||
|
||||
if total and total > 0:
|
||||
# Always filter out non-byte progress bars (e.g., "Fetching 12 files")
|
||||
# These cause crazy percentages because they're counting files, not bytes
|
||||
if self._is_non_byte_progress(filename):
|
||||
return result
|
||||
|
||||
# When model is cached, also filter out generation-related progress
|
||||
if tracker.filter_non_downloads:
|
||||
if not self._is_download_progress(filename):
|
||||
return result
|
||||
|
||||
# Update per-file tracking
|
||||
tracker._file_sizes[filename] = total
|
||||
tracker._file_downloaded[filename] = current
|
||||
@@ -97,6 +109,13 @@ class HFProgressTracker:
|
||||
tracker._total_size = sum(tracker._file_sizes.values())
|
||||
tracker._total_downloaded = sum(tracker._file_downloaded.values())
|
||||
|
||||
# Only report progress once we have a meaningful total (at least 1MB)
|
||||
# This avoids the "100% at 0MB" issue when small config
|
||||
# files are counted before the real model files
|
||||
MIN_TOTAL_BYTES = 1_000_000 # 1MB
|
||||
if tracker._total_size < MIN_TOTAL_BYTES:
|
||||
return result
|
||||
|
||||
# Call progress callback
|
||||
if tracker.progress_callback:
|
||||
tracker.progress_callback(
|
||||
@@ -107,6 +126,50 @@ class HFProgressTracker:
|
||||
|
||||
return result
|
||||
|
||||
def _is_non_byte_progress(self, filename: str) -> bool:
|
||||
"""Check if this progress bar should be SKIPPED (returns True to skip).
|
||||
|
||||
We want to track byte-based progress bars. This method identifies
|
||||
progress bars that count files/items instead of bytes, which would
|
||||
cause crazy percentages if mixed with our byte counting.
|
||||
|
||||
Returns:
|
||||
True = SKIP this bar (it's not byte-based)
|
||||
False = TRACK this bar (it counts bytes)
|
||||
"""
|
||||
if not filename:
|
||||
return False
|
||||
|
||||
filename_lower = filename.lower()
|
||||
|
||||
# Skip "Fetching X files" - it counts files (total=12), not bytes
|
||||
# Don't skip "Downloading (incomplete total...)" - that IS byte-based
|
||||
skip_patterns = [
|
||||
'fetching', # "Fetching 12 files" has total=12 files, not bytes
|
||||
]
|
||||
return any(pattern in filename_lower for pattern in skip_patterns)
|
||||
|
||||
def _is_download_progress(self, filename: str) -> bool:
|
||||
"""Check if this is a real file download progress bar vs internal processing."""
|
||||
if not filename or filename == "unknown":
|
||||
return False
|
||||
|
||||
# Real downloads have file extensions
|
||||
download_extensions = [
|
||||
'.safetensors', '.bin', '.pt', '.pth', # Model weights
|
||||
'.json', '.txt', '.py', # Config files
|
||||
'.msgpack', '.h5', # Other formats
|
||||
]
|
||||
|
||||
filename_lower = filename.lower()
|
||||
has_extension = any(filename_lower.endswith(ext) for ext in download_extensions)
|
||||
|
||||
# Skip generation-related progress indicators
|
||||
skip_patterns = ['segment', 'processing', 'generating', 'loading']
|
||||
has_skip_pattern = any(pattern in filename_lower for pattern in skip_patterns)
|
||||
|
||||
return has_extension and not has_skip_pattern
|
||||
|
||||
def close(self):
|
||||
with tracker._lock:
|
||||
if id(self) in tracker._active_tqdms:
|
||||
@@ -120,7 +183,7 @@ class HFProgressTracker:
|
||||
"""Context manager to patch tqdm for progress tracking."""
|
||||
try:
|
||||
import tqdm as tqdm_module
|
||||
|
||||
|
||||
# Store original tqdm class
|
||||
self._original_tqdm_class = tqdm_module.tqdm
|
||||
|
||||
@@ -135,10 +198,10 @@ class HFProgressTracker:
|
||||
|
||||
# Create our tracked tqdm class
|
||||
tracked_tqdm = self._create_tracked_tqdm_class()
|
||||
|
||||
|
||||
# Patch tqdm.tqdm
|
||||
tqdm_module.tqdm = tracked_tqdm
|
||||
|
||||
|
||||
# Also patch tqdm.auto.tqdm if it exists (used by huggingface_hub)
|
||||
self._original_tqdm_auto = None
|
||||
if hasattr(tqdm_module, "auto") and hasattr(tqdm_module.auto, "tqdm"):
|
||||
@@ -146,22 +209,79 @@ class HFProgressTracker:
|
||||
tqdm_module.auto.tqdm = tracked_tqdm
|
||||
|
||||
# Patch in sys.modules to catch already-imported references
|
||||
# huggingface_hub uses: from tqdm.auto import tqdm as base_tqdm
|
||||
# So we need to patch both 'tqdm' and 'base_tqdm' attributes
|
||||
self._patched_modules = {}
|
||||
tqdm_attr_names = ['tqdm', 'base_tqdm', 'old_tqdm'] # Various names used
|
||||
|
||||
patched_count = 0
|
||||
for module_name in list(sys.modules.keys()):
|
||||
if "huggingface" in module_name or module_name.startswith("tqdm"):
|
||||
try:
|
||||
module = sys.modules[module_name]
|
||||
if hasattr(module, "tqdm"):
|
||||
attr = getattr(module, "tqdm")
|
||||
# Only patch if it's the original tqdm class (not already patched)
|
||||
if attr is self._original_tqdm_class or (
|
||||
hasattr(attr, "__name__") and attr.__name__ == "tqdm"
|
||||
):
|
||||
self._patched_modules[module_name] = attr
|
||||
setattr(module, "tqdm", tracked_tqdm)
|
||||
for attr_name in tqdm_attr_names:
|
||||
if hasattr(module, attr_name):
|
||||
attr = getattr(module, attr_name)
|
||||
# Only patch if it's a tqdm class (not already patched)
|
||||
is_tqdm_class = (
|
||||
attr is self._original_tqdm_class or
|
||||
(self._original_tqdm_auto and attr is self._original_tqdm_auto) or
|
||||
(hasattr(attr, "__name__") and attr.__name__ == "tqdm" and
|
||||
hasattr(attr, "update")) # tqdm classes have update method
|
||||
)
|
||||
if is_tqdm_class:
|
||||
key = f"{module_name}.{attr_name}"
|
||||
self._patched_modules[key] = (module, attr_name, attr)
|
||||
setattr(module, attr_name, tracked_tqdm)
|
||||
patched_count += 1
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
# ALSO monkey-patch the update method on huggingface_hub's tqdm class
|
||||
# This is needed because the class was already defined at import time
|
||||
self._hf_tqdm_original_update = None
|
||||
try:
|
||||
from huggingface_hub.utils import tqdm as hf_tqdm_module
|
||||
if hasattr(hf_tqdm_module, 'tqdm'):
|
||||
hf_tqdm_class = hf_tqdm_module.tqdm
|
||||
self._hf_tqdm_original_update = hf_tqdm_class.update
|
||||
|
||||
# Create a wrapper that calls our tracking
|
||||
tracker = self # Reference to HFProgressTracker instance
|
||||
def patched_update(tqdm_self, n=1):
|
||||
result = tracker._hf_tqdm_original_update(tqdm_self, n)
|
||||
|
||||
# Track this progress
|
||||
with tracker._lock:
|
||||
desc = getattr(tqdm_self, 'desc', '') or ''
|
||||
current = getattr(tqdm_self, 'n', 0)
|
||||
total = getattr(tqdm_self, 'total', 0) or 0
|
||||
|
||||
# Skip non-byte progress bars
|
||||
if 'fetching' in desc.lower():
|
||||
return result
|
||||
|
||||
# Skip until we have a meaningful total (at least 1MB)
|
||||
# This avoids the "100% at 0MB" issue when small config
|
||||
# files are counted before the real model files
|
||||
MIN_TOTAL_BYTES = 1_000_000 # 1MB
|
||||
if total >= MIN_TOTAL_BYTES:
|
||||
tracker._total_downloaded = current
|
||||
tracker._total_size = total
|
||||
|
||||
if tracker.progress_callback:
|
||||
tracker.progress_callback(current, total, desc)
|
||||
|
||||
return result
|
||||
|
||||
hf_tqdm_class.update = patched_update
|
||||
patched_count += 1
|
||||
print(f"[HFProgressTracker] Monkey-patched huggingface_hub.utils.tqdm.tqdm.update")
|
||||
except (ImportError, AttributeError) as e:
|
||||
print(f"[HFProgressTracker] Could not monkey-patch hf_tqdm: {e}")
|
||||
|
||||
print(f"[HFProgressTracker] Patched {patched_count} tqdm references")
|
||||
|
||||
yield
|
||||
|
||||
except ImportError:
|
||||
@@ -178,15 +298,24 @@ class HFProgressTracker:
|
||||
tqdm_module.auto.tqdm = self._original_tqdm_auto
|
||||
|
||||
# Restore patched modules
|
||||
for module_name, original in self._patched_modules.items():
|
||||
for key, (module, attr_name, original) in self._patched_modules.items():
|
||||
try:
|
||||
module = sys.modules.get(module_name)
|
||||
if module and original:
|
||||
setattr(module, "tqdm", original)
|
||||
setattr(module, attr_name, original)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
self._patched_modules = {}
|
||||
|
||||
# Restore hf_tqdm's original update method
|
||||
if self._hf_tqdm_original_update:
|
||||
try:
|
||||
from huggingface_hub.utils import tqdm as hf_tqdm_module
|
||||
if hasattr(hf_tqdm_module, 'tqdm'):
|
||||
hf_tqdm_module.tqdm.update = self._hf_tqdm_original_update
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
self._hf_tqdm_original_update = None
|
||||
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
@@ -194,13 +323,17 @@ class HFProgressTracker:
|
||||
def create_hf_progress_callback(model_name: str, progress_manager):
|
||||
"""Create a progress callback for HuggingFace downloads."""
|
||||
def callback(downloaded: int, total: int, filename: str = ""):
|
||||
"""Progress callback."""
|
||||
if total > 0:
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=downloaded,
|
||||
total=total,
|
||||
filename=filename or "",
|
||||
status="downloading",
|
||||
)
|
||||
"""Progress callback.
|
||||
|
||||
Note: We send updates even when total=0 (unknown) to provide feedback
|
||||
during the "incomplete total" phase of huggingface_hub downloads.
|
||||
The frontend handles total=0 gracefully.
|
||||
"""
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=downloaded,
|
||||
total=total,
|
||||
filename=filename or "",
|
||||
status="downloading",
|
||||
)
|
||||
return callback
|
||||
|
||||
@@ -16,11 +16,17 @@ class ProgressManager:
|
||||
Thread-safe: can be called from background threads (e.g., via asyncio.to_thread).
|
||||
"""
|
||||
|
||||
# Throttle settings to prevent overwhelming SSE clients
|
||||
THROTTLE_INTERVAL_SECONDS = 0.5 # Minimum time between updates
|
||||
THROTTLE_PROGRESS_DELTA = 1.0 # Minimum progress change (%) to force update
|
||||
|
||||
def __init__(self):
|
||||
self._progress: Dict[str, Dict] = {}
|
||||
self._listeners: Dict[str, list] = {}
|
||||
self._lock = threading.Lock() # Thread-safe lock for progress dict
|
||||
self._main_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._last_notify_time: Dict[str, float] = {} # Last notification time per model
|
||||
self._last_notify_progress: Dict[str, float] = {} # Last notified progress per model
|
||||
|
||||
def _set_main_loop(self, loop: asyncio.AbstractEventLoop):
|
||||
"""Set the main event loop for thread-safe operations."""
|
||||
@@ -65,8 +71,12 @@ class ProgressManager:
|
||||
):
|
||||
"""
|
||||
Update progress for a model download.
|
||||
|
||||
|
||||
Thread-safe: can be called from background threads.
|
||||
|
||||
Progress updates are throttled to prevent overwhelming SSE clients.
|
||||
Updates are sent at most every THROTTLE_INTERVAL_SECONDS, or when
|
||||
progress changes by at least THROTTLE_PROGRESS_DELTA percent.
|
||||
|
||||
Args:
|
||||
model_name: Name of the model (e.g., "qwen-tts-1.7B", "whisper-base")
|
||||
@@ -76,9 +86,17 @@ class ProgressManager:
|
||||
status: Status string (downloading, extracting, complete, error)
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
progress_pct = (current / total * 100) if total > 0 else 0
|
||||
# Calculate progress percentage, clamped to 0-100 range
|
||||
# This prevents crazy percentages from edge cases like:
|
||||
# - current > total temporarily during aggregation
|
||||
# - mixing file-count progress with byte-count progress
|
||||
if total > 0:
|
||||
progress_pct = min(100.0, max(0.0, (current / total * 100)))
|
||||
else:
|
||||
progress_pct = 0
|
||||
|
||||
progress_data = {
|
||||
"model_name": model_name,
|
||||
@@ -89,13 +107,36 @@ class ProgressManager:
|
||||
"status": status,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# Thread-safe update of progress dict
|
||||
|
||||
# Thread-safe update of progress dict (always update internal state)
|
||||
with self._lock:
|
||||
self._progress[model_name] = progress_data
|
||||
|
||||
# Check if we should notify listeners (throttling)
|
||||
current_time = time.time()
|
||||
last_time = self._last_notify_time.get(model_name, 0)
|
||||
last_progress = self._last_notify_progress.get(model_name, -100)
|
||||
|
||||
time_delta = current_time - last_time
|
||||
progress_delta = abs(progress_pct - last_progress)
|
||||
|
||||
# Always notify for complete/error status, or if throttle conditions are met
|
||||
should_notify = (
|
||||
status in ("complete", "error") or
|
||||
time_delta >= self.THROTTLE_INTERVAL_SECONDS or
|
||||
progress_delta >= self.THROTTLE_PROGRESS_DELTA
|
||||
)
|
||||
|
||||
if not should_notify:
|
||||
return # Skip this update (throttled)
|
||||
|
||||
# Update throttle tracking
|
||||
self._last_notify_time[model_name] = current_time
|
||||
self._last_notify_progress[model_name] = progress_pct
|
||||
|
||||
# Notify all listeners (thread-safe)
|
||||
listener_count = len(self._listeners.get(model_name, []))
|
||||
|
||||
if listener_count > 0:
|
||||
logger.debug(f"Notifying {listener_count} listeners for {model_name}: {progress_pct:.1f}% ({filename})")
|
||||
self._notify_listeners_threadsafe(model_name, progress_data)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,10 +4,15 @@ from PyInstaller.utils.hooks import collect_submodules
|
||||
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.asr']
|
||||
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=[],
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"app": {
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.11",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -68,7 +68,7 @@
|
||||
},
|
||||
"landing": {
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.11",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
@@ -93,10 +93,14 @@
|
||||
},
|
||||
"tauri": {
|
||||
"name": "@voicebox/tauri",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.11",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
@@ -112,7 +116,7 @@
|
||||
},
|
||||
"web": {
|
||||
"name": "@voicebox/web",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.11",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"react": "^18.3.0",
|
||||
|
||||
@@ -162,7 +162,7 @@ chmod +x voicebox-*.AppImage
|
||||
**Solutions:**
|
||||
1. **Check server is running**
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
curl http://localhost:17493/health
|
||||
```
|
||||
|
||||
2. **Check remote mode**
|
||||
@@ -170,7 +170,7 @@ chmod +x voicebox-*.AppImage
|
||||
- Check firewall settings
|
||||
|
||||
3. **Check port availability**
|
||||
- Default port is 8000
|
||||
- The current local app and dev workflow uses port 17493 by default
|
||||
- Ensure no other service is using it
|
||||
|
||||
### CORS errors in browser
|
||||
@@ -276,7 +276,7 @@ chmod +x voicebox-*.AppImage
|
||||
|
||||
2. **Check OpenAPI endpoint**
|
||||
```bash
|
||||
curl http://localhost:8000/openapi.json
|
||||
curl http://localhost:17493/openapi.json
|
||||
```
|
||||
|
||||
3. **Regenerate client**
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "Welcome to Voicebox - the open-source voice synthesis studio"
|
||||
|
||||
## What is Voicebox?
|
||||
|
||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as the **Ollama for voice** — download models, clone voices, and generate speech entirely on your machine.
|
||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as a **local, free and open-source alternative to ElevenLabs** — download models, clone voices, and generate speech entirely on your machine.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/app-screenshot-1.webp" alt="Voicebox App Screenshot" />
|
||||
|
||||
@@ -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. |
|
||||
@@ -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
|
||||
@@ -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** | |
|
||||
@@ -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>
|
||||
@@ -0,0 +1,964 @@
|
||||
# TTS Provider Architecture
|
||||
|
||||
**Status:** Planned for v0.1.13
|
||||
**Created:** 2025-01-31
|
||||
**Problem:** GitHub 2GB release limit + poor UX for frequent updates requiring 2.4GB re-downloads
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Split the monolithic backend into modular components:
|
||||
|
||||
1. **Main App** (~150-200MB): Tauri + FastAPI backend + Whisper + UI/profiles/history
|
||||
2. **TTS Providers** (downloadable plugins): Separate executables for model inference
|
||||
|
||||
This architecture solves:
|
||||
|
||||
- ✅ GitHub 2GB release artifact limit
|
||||
- ✅ Frequent app updates without re-downloading large python binaries
|
||||
- ✅ User choice of compute backend (CPU/GPU/Cloud)
|
||||
- ✅ External provider support (OpenAI, custom servers)
|
||||
- ✅ Future extensibility
|
||||
|
||||
---
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Voicebox App (Tauri + Backend) ~150MB │
|
||||
│ ├─ UI Layer (React) │
|
||||
│ ├─ Backend (FastAPI) │
|
||||
│ │ ├─ Voice Profiles │
|
||||
│ │ ├─ Generation History │
|
||||
│ │ ├─ Audio Editing / Stories │
|
||||
│ │ └─ Provider Manager ◄──────────────┐ │
|
||||
│ └─ Whisper (bundled, tiny ~50MB) │ │
|
||||
└─────────────────────────────────────────┼────────────────┘
|
||||
│
|
||||
HTTP/IPC │
|
||||
│
|
||||
┌────────────────────────────────┼─────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐
|
||||
│ TTS Provider: │ │ TTS Provider: │ │ TTS Provider: │
|
||||
│ PyTorch CPU │ │ PyTorch CUDA │ │ MLX (Apple) │
|
||||
│ │ │ │ │ │
|
||||
│ ~300MB │ │ ~2.4GB │ │ ~800MB │
|
||||
│ │ │ │ │ │
|
||||
│ Local inference │ │ GPU inference │ │ Metal inference │
|
||||
└─────────────────┘ └─────────────────┘ └──────────────────┘
|
||||
│ │ │
|
||||
└────────────────────────┴─────────────────────┘
|
||||
│
|
||||
┌─────────────▼──────────────┐
|
||||
│ Future Providers: │
|
||||
│ • Remote Server │
|
||||
│ • OpenAI API │
|
||||
│ • ElevenLabs │
|
||||
│ • Custom Docker Container │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### Current Architecture Issues
|
||||
|
||||
**Monolithic Binary:**
|
||||
|
||||
- CPU version: ~295MB
|
||||
- CUDA version: ~2.37GB
|
||||
- GitHub releases: 2GB file size limit (BLOCKED)
|
||||
- Updates require re-downloading entire binary
|
||||
- Poor UX: update app → restart → download CUDA update → restart again
|
||||
|
||||
**User Pain Points:**
|
||||
|
||||
1. Cannot release CUDA version on GitHub (over 2GB)
|
||||
2. Every app update forces 2.4GB re-download for GPU users
|
||||
3. No flexibility (can't use OpenAI, remote servers, etc.)
|
||||
4. Wastes bandwidth for small bug fixes
|
||||
|
||||
---
|
||||
|
||||
## Solution: Pluggable TTS Providers
|
||||
|
||||
### Component Breakdown
|
||||
|
||||
#### 1. Main App (voicebox.exe / .app / .AppImage)
|
||||
|
||||
**Size:** ~100-150MB
|
||||
|
||||
**Includes:**
|
||||
|
||||
- Tauri runtime + React UI
|
||||
- FastAPI backend (pure Python, no PyTorch)
|
||||
- Whisper model (tiny, ~50MB)
|
||||
- SQLite database
|
||||
- Profile/history/audio editing logic
|
||||
- Provider management system
|
||||
|
||||
**Does NOT include:**
|
||||
|
||||
- PyTorch (CPU or CUDA)
|
||||
- TTS models (Qwen3-TTS)
|
||||
- Heavy ML dependencies
|
||||
|
||||
**Updates frequently:** UI fixes, feature additions, non-ML changes
|
||||
|
||||
---
|
||||
|
||||
#### 2. TTS Provider: PyTorch CPU
|
||||
|
||||
**Binary:** `tts-provider-pytorch-cpu.exe`
|
||||
**Size:** ~200MB
|
||||
|
||||
**Includes:**
|
||||
|
||||
- PyTorch CPU build
|
||||
- Qwen3-TTS package
|
||||
- Transformers
|
||||
- No CUDA libraries
|
||||
|
||||
**Download source:** Cloudflare R2
|
||||
**Updates rarely:** Only when model code changes
|
||||
|
||||
---
|
||||
|
||||
#### 3. TTS Provider: PyTorch CUDA
|
||||
|
||||
**Binary:** `tts-provider-pytorch-cuda.exe`
|
||||
**Size:** ~2.4GB
|
||||
|
||||
**Includes:**
|
||||
|
||||
- PyTorch CUDA build (cu121)
|
||||
- Qwen3-TTS package
|
||||
- CUDA runtime, cuDNN, cuBLAS
|
||||
- Transformers
|
||||
|
||||
**Download source:** Cloudflare R2
|
||||
**Platform:** Windows + Linux (NVIDIA GPU)
|
||||
**Updates rarely:** Only when model code or CUDA version changes
|
||||
|
||||
---
|
||||
|
||||
#### 4. TTS Provider: MLX
|
||||
|
||||
**Binary:** `tts-provider-mlx`
|
||||
**Size:** ~150MB
|
||||
|
||||
**Includes:**
|
||||
|
||||
- MLX framework
|
||||
- MLX-optimized Qwen3-TTS
|
||||
- Metal acceleration
|
||||
|
||||
**Platform:** macOS only (Apple Silicon)
|
||||
**Download source:** Cloudflare R2
|
||||
|
||||
---
|
||||
|
||||
#### 5. TTS Provider: Remote
|
||||
|
||||
**Binary:** None (built-in config)
|
||||
**Size:** 0MB
|
||||
|
||||
**How it works:**
|
||||
|
||||
- User provides URL to their own TTS server
|
||||
- Backend proxies requests to that server
|
||||
- Implements API spec from `EXTERNAL_PROVIDERS.md`
|
||||
|
||||
**Use cases:**
|
||||
|
||||
- AMD GPU users running their own server
|
||||
- Team deployments with shared GPU server
|
||||
- Cloud hosting (Modal, RunPod, Replicate)
|
||||
|
||||
---
|
||||
|
||||
#### 6. TTS Provider: OpenAI
|
||||
|
||||
**Binary:** None (API wrapper)
|
||||
**Size:** 0MB
|
||||
|
||||
**How it works:**
|
||||
|
||||
- User provides OpenAI API key
|
||||
- Backend wraps OpenAI Audio API
|
||||
- Voice profiles map to OpenAI voices
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Zero local compute
|
||||
- Pay-per-use
|
||||
- Instant setup
|
||||
|
||||
---
|
||||
|
||||
## Communication Protocol
|
||||
|
||||
### Provider API Specification
|
||||
|
||||
All TTS providers must implement these endpoints:
|
||||
|
||||
#### POST /tts/generate
|
||||
|
||||
Generate speech from text.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "Hello world!",
|
||||
"voice_prompt": {
|
||||
/* voice prompt object */
|
||||
},
|
||||
"language": "en",
|
||||
"seed": 12345,
|
||||
"model_size": "1.7B"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"audio": "base64-encoded-audio",
|
||||
"sample_rate": 24000,
|
||||
"duration": 2.5
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /tts/create_voice_prompt
|
||||
|
||||
Create voice prompt from reference audio.
|
||||
|
||||
**Request:** (multipart/form-data)
|
||||
|
||||
- `audio`: Audio file
|
||||
- `reference_text`: Transcript
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"voice_prompt": {
|
||||
/* serialized prompt */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /tts/health
|
||||
|
||||
Health check.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"provider": "pytorch-cuda",
|
||||
"version": "1.0.0",
|
||||
"model": "Qwen3-TTS-12Hz-1.7B-Base",
|
||||
"device": "cuda:0"
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /tts/status
|
||||
|
||||
Model status.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_loaded": true,
|
||||
"model_size": "1.7B",
|
||||
"available_sizes": ["0.6B", "1.7B"],
|
||||
"gpu_available": true,
|
||||
"vram_used_mb": 1234
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### Provider Manager
|
||||
|
||||
**File:** `backend/providers/__init__.py`
|
||||
|
||||
```python
|
||||
class ProviderManager:
|
||||
"""Manages TTS provider lifecycle."""
|
||||
|
||||
def __init__(self):
|
||||
self.active_provider: Optional[Provider] = None
|
||||
self.config = load_provider_config()
|
||||
|
||||
async def start_provider(self, provider_type: str) -> str:
|
||||
"""Start a TTS provider process."""
|
||||
if provider_type == "pytorch-cpu":
|
||||
return await self._start_local_provider("tts-provider-pytorch-cpu.exe")
|
||||
elif provider_type == "pytorch-cuda":
|
||||
return await self._start_local_provider("tts-provider-pytorch-cuda.exe")
|
||||
elif provider_type == "mlx":
|
||||
return await self._start_local_provider("tts-provider-mlx")
|
||||
elif provider_type == "remote":
|
||||
return self.config["remote_url"]
|
||||
elif provider_type == "openai":
|
||||
return None # No subprocess, API wrapper
|
||||
|
||||
async def _start_local_provider(self, binary_name: str) -> str:
|
||||
"""Start local provider subprocess."""
|
||||
provider_path = get_provider_binary_path(binary_name)
|
||||
|
||||
if not provider_path.exists():
|
||||
raise ProviderNotInstalledException(binary_name)
|
||||
|
||||
# Start subprocess on random port
|
||||
port = get_free_port()
|
||||
process = subprocess.Popen([
|
||||
str(provider_path),
|
||||
"--port", str(port),
|
||||
"--data-dir", str(config.get_data_dir())
|
||||
])
|
||||
|
||||
# Wait for provider to be ready
|
||||
await wait_for_provider_health(f"http://localhost:{port}")
|
||||
|
||||
self.active_provider = Provider(process, port)
|
||||
return f"http://localhost:{port}"
|
||||
|
||||
async def stop_provider(self):
|
||||
"""Stop active provider."""
|
||||
if self.active_provider:
|
||||
self.active_provider.process.terminate()
|
||||
self.active_provider = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Provider Abstraction
|
||||
|
||||
**File:** `backend/providers/base.py`
|
||||
|
||||
```python
|
||||
class TTSProvider(ABC):
|
||||
"""Abstract base for TTS providers."""
|
||||
|
||||
@abstractmethod
|
||||
async def generate(
|
||||
self,
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str,
|
||||
seed: Optional[int]
|
||||
) -> tuple[np.ndarray, int]:
|
||||
"""Generate speech audio."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def create_voice_prompt(
|
||||
self,
|
||||
audio_path: str,
|
||||
reference_text: str
|
||||
) -> dict:
|
||||
"""Create voice prompt from reference audio."""
|
||||
pass
|
||||
```
|
||||
|
||||
**File:** `backend/providers/local.py`
|
||||
|
||||
```python
|
||||
class LocalProvider(TTSProvider):
|
||||
"""Provider that communicates with local subprocess via HTTP."""
|
||||
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url
|
||||
self.client = httpx.AsyncClient()
|
||||
|
||||
async def generate(self, text, voice_prompt, language, seed):
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/tts/generate",
|
||||
json={
|
||||
"text": text,
|
||||
"voice_prompt": voice_prompt,
|
||||
"language": language,
|
||||
"seed": seed
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
audio = np.frombuffer(base64.b64decode(data["audio"]), dtype=np.float32)
|
||||
return audio, data["sample_rate"]
|
||||
```
|
||||
|
||||
**File:** `backend/providers/openai.py`
|
||||
|
||||
```python
|
||||
class OpenAIProvider(TTSProvider):
|
||||
"""Provider that wraps OpenAI Audio API."""
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
self.client = OpenAI(api_key=api_key)
|
||||
|
||||
async def generate(self, text, voice_prompt, language, seed):
|
||||
# Map voice_prompt to OpenAI voice name
|
||||
voice = map_profile_to_openai_voice(voice_prompt)
|
||||
|
||||
response = await self.client.audio.speech.create(
|
||||
model="tts-1",
|
||||
voice=voice,
|
||||
input=text
|
||||
)
|
||||
|
||||
# Convert to numpy array
|
||||
audio_data = response.content
|
||||
audio, sr = load_audio_from_bytes(audio_data)
|
||||
return audio, sr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Provider Installation
|
||||
|
||||
### Download Manager
|
||||
|
||||
**File:** `backend/providers/installer.py`
|
||||
|
||||
```python
|
||||
class ProviderInstaller:
|
||||
"""Handles provider download and installation."""
|
||||
|
||||
async def download_provider(self, provider_type: str):
|
||||
"""Download provider binary from R2."""
|
||||
|
||||
binary_name = {
|
||||
"pytorch-cpu": "tts-provider-pytorch-cpu.exe",
|
||||
"pytorch-cuda": "tts-provider-pytorch-cuda.exe",
|
||||
"mlx": "tts-provider-mlx"
|
||||
}[provider_type]
|
||||
|
||||
download_url = f"https://downloads.voicebox.sh/providers/v{PROVIDER_VERSION}/{binary_name}"
|
||||
|
||||
# Download with progress tracking (reuse existing SSE system)
|
||||
await download_with_progress(
|
||||
url=download_url,
|
||||
destination=get_provider_install_path(binary_name),
|
||||
progress_key=f"provider-{provider_type}"
|
||||
)
|
||||
```
|
||||
|
||||
**Provider Storage Location:**
|
||||
|
||||
- Windows: `%APPDATA%/voicebox/providers/`
|
||||
- macOS: `~/Library/Application Support/voicebox/providers/`
|
||||
- Linux: `~/.local/share/voicebox/providers/`
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### Provider Settings UI
|
||||
|
||||
**Component:** `app/src/components/ServerSettings/ProviderSettings.tsx`
|
||||
|
||||
```tsx
|
||||
export function ProviderSettings() {
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
useState<ProviderType>("auto");
|
||||
const {data: installedProviders} = useQuery({
|
||||
queryKey: ["providers", "installed"],
|
||||
queryFn: () => apiClient.getInstalledProviders(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>TTS Provider</CardTitle>
|
||||
<CardDescription>Choose how Voicebox generates speech</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup
|
||||
value={selectedProvider}
|
||||
onValueChange={setSelectedProvider}
|
||||
>
|
||||
{/* Auto-detect */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="auto" id="auto" />
|
||||
<Label htmlFor="auto">
|
||||
<div className="font-medium">Auto-detect (Recommended)</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Automatically choose the best available provider
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* PyTorch CUDA */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="pytorch-cuda"
|
||||
id="cuda"
|
||||
disabled={!gpuAvailable}
|
||||
/>
|
||||
<Label htmlFor="cuda">
|
||||
<div className="font-medium">PyTorch CUDA (NVIDIA GPU)</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
4-5x faster inference on NVIDIA GPUs
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{!installedProviders?.includes("pytorch-cuda") && gpuAvailable && (
|
||||
<Button
|
||||
onClick={() => downloadProvider("pytorch-cuda")}
|
||||
size="sm"
|
||||
>
|
||||
Download (2.4GB)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PyTorch CPU */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="pytorch-cpu" id="cpu" />
|
||||
<Label htmlFor="cpu">
|
||||
<div className="font-medium">PyTorch CPU</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Works on any system, slower inference
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{!installedProviders?.includes("pytorch-cpu") && (
|
||||
<Button onClick={() => downloadProvider("pytorch-cpu")} size="sm">
|
||||
Download (300MB)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MLX (macOS only) */}
|
||||
{isMacOS && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="mlx" id="mlx" />
|
||||
<Label htmlFor="mlx">
|
||||
<div className="font-medium">MLX (Apple Silicon)</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Optimized for M1/M2/M3 chips
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{!installedProviders?.includes("mlx") && (
|
||||
<Button onClick={() => downloadProvider("mlx")} size="sm">
|
||||
Download (800MB)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="remote" id="remote" />
|
||||
<Label htmlFor="remote">
|
||||
<div className="font-medium">Remote Server</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Connect to your own TTS server
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{selectedProvider === "remote" && (
|
||||
<Input placeholder="http://your-server:8000" className="ml-6" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OpenAI */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="openai" id="openai" />
|
||||
<Label htmlFor="openai">
|
||||
<div className="font-medium">OpenAI API</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Use OpenAI's TTS API (requires API key)
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{selectedProvider === "openai" && (
|
||||
<Input type="password" placeholder="sk-..." className="ml-6" />
|
||||
)}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
voicebox/
|
||||
├── backend/
|
||||
│ ├── main.py # Main FastAPI app (no TTS code)
|
||||
│ ├── providers/
|
||||
│ │ ├── __init__.py # ProviderManager
|
||||
│ │ ├── base.py # TTSProvider ABC
|
||||
│ │ ├── local.py # LocalProvider (subprocess)
|
||||
│ │ ├── remote.py # RemoteProvider (HTTP)
|
||||
│ │ ├── openai.py # OpenAIProvider (API wrapper)
|
||||
│ │ └── installer.py # Provider download logic
|
||||
│ ├── profiles.py # Voice profile management
|
||||
│ ├── history.py # Generation history
|
||||
│ ├── transcribe.py # Whisper (still bundled)
|
||||
│ └── ... (other backend modules)
|
||||
│
|
||||
├── providers/
|
||||
│ ├── pytorch-cpu/
|
||||
│ │ ├── main.py # FastAPI server for TTS
|
||||
│ │ ├── tts_backend.py # PyTorch TTS logic
|
||||
│ │ ├── requirements.txt # torch (CPU), qwen-tts, transformers
|
||||
│ │ └── build.spec # PyInstaller spec
|
||||
│ │
|
||||
│ ├── pytorch-cuda/
|
||||
│ │ ├── main.py # FastAPI server for TTS
|
||||
│ │ ├── tts_backend.py # PyTorch TTS logic
|
||||
│ │ ├── requirements.txt # torch+cu121, qwen-tts, transformers
|
||||
│ │ └── build.spec # PyInstaller spec
|
||||
│ │
|
||||
│ └── mlx/
|
||||
│ ├── main.py # FastAPI server for TTS
|
||||
│ ├── mlx_backend.py # MLX TTS logic
|
||||
│ ├── requirements.txt # mlx, qwen-tts-mlx
|
||||
│ └── build.spec # PyInstaller spec
|
||||
│
|
||||
├── app/ # Frontend (Tauri + React)
|
||||
│ └── src/
|
||||
│ └── components/
|
||||
│ └── ServerSettings/
|
||||
│ └── ProviderSettings.tsx
|
||||
│
|
||||
└── tauri/
|
||||
└── src-tauri/
|
||||
└── tauri.conf.json # No externalBin for providers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Refactor Backend (No User Changes)
|
||||
|
||||
**Goal:** Abstract TTS behind provider interface
|
||||
|
||||
1. Create `backend/providers/` module structure
|
||||
2. Implement `TTSProvider` abstract base class
|
||||
3. Create `LocalProvider` wrapper for current PyTorch code
|
||||
4. Modify `backend/tts.py` to use provider abstraction
|
||||
5. Keep PyTorch bundled in main app
|
||||
|
||||
**Result:** Code is prepared, but user experience unchanged
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Build Provider Binaries
|
||||
|
||||
**Goal:** Create standalone TTS provider executables
|
||||
|
||||
1. Create separate PyInstaller specs for each provider
|
||||
2. Build provider executables:
|
||||
- `tts-provider-pytorch-cpu.exe` (~300MB)
|
||||
- `tts-provider-pytorch-cuda.exe` (~2.4GB)
|
||||
- `tts-provider-mlx` (~800MB, macOS)
|
||||
3. Test subprocess communication
|
||||
4. Upload providers to Cloudflare R2
|
||||
|
||||
**Result:** Provider binaries exist but aren't used yet
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Remove PyTorch from Main App
|
||||
|
||||
**Goal:** Split main app from providers
|
||||
|
||||
1. Exclude PyTorch/Qwen3-TTS from main app PyInstaller spec
|
||||
2. Main app now requires provider download
|
||||
3. Update GitHub CI to build multiple artifacts:
|
||||
- `voicebox-{version}-{platform}.exe` (~150MB)
|
||||
- `tts-provider-pytorch-cpu-{version}.exe`
|
||||
- `tts-provider-pytorch-cuda-{version}.exe`
|
||||
- `tts-provider-mlx-{version}` (macOS)
|
||||
|
||||
**Result:** Main app is small, providers downloaded separately
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Add Provider UI
|
||||
|
||||
**Goal:** User-facing provider management
|
||||
|
||||
1. Create Provider Settings page
|
||||
2. Implement provider download UI
|
||||
3. Add provider status indicators
|
||||
4. Show active provider in UI
|
||||
|
||||
**Result:** Users can choose and download providers
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: External Providers
|
||||
|
||||
**Goal:** Enable remote and cloud providers
|
||||
|
||||
1. Implement `RemoteProvider` (HTTP client)
|
||||
2. Implement `OpenAIProvider` (API wrapper)
|
||||
3. Add provider configuration UI (URLs, API keys)
|
||||
4. Document external provider API spec
|
||||
|
||||
**Result:** Full provider ecosystem
|
||||
|
||||
---
|
||||
|
||||
## Provider Versioning
|
||||
|
||||
### Independent Versioning
|
||||
|
||||
Providers have their own version numbers, independent of the main app:
|
||||
|
||||
- **App version:** `v0.2.0` (frequent updates)
|
||||
- **Provider version:** `v1.0.0` (rare updates)
|
||||
|
||||
### Compatibility Matrix
|
||||
|
||||
**Example:**
|
||||
|
||||
| App Version | Min Provider Version | Max Provider Version |
|
||||
| ----------- | -------------------- | -------------------- |
|
||||
| v0.2.0 | v1.0.0 | v1.x.x |
|
||||
| v0.3.0 | v1.0.0 | v1.x.x |
|
||||
| v0.4.0 | v1.2.0 | v1.x.x |
|
||||
| v1.0.0 | v2.0.0 | v2.x.x |
|
||||
|
||||
**Backend checks compatibility:**
|
||||
|
||||
```python
|
||||
async def check_provider_compatibility(provider_version: str) -> bool:
|
||||
"""Check if provider version is compatible with current app."""
|
||||
min_version = "1.0.0"
|
||||
max_version = "1.999.999"
|
||||
return min_version <= provider_version < max_version
|
||||
```
|
||||
|
||||
**UI shows warning if incompatible:**
|
||||
|
||||
```
|
||||
⚠️ Provider version 0.9.0 is outdated. Update to v1.0.0+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Flows
|
||||
|
||||
### First-Time Setup
|
||||
|
||||
1. User downloads and installs Voicebox (~150MB)
|
||||
2. App launches → detects no TTS provider installed
|
||||
3. Shows setup wizard:
|
||||
|
||||
```
|
||||
Choose your TTS provider:
|
||||
|
||||
[ ] PyTorch CUDA (2.4GB) [Download]
|
||||
✓ Fastest on NVIDIA GPUs
|
||||
✗ Requires NVIDIA GPU
|
||||
|
||||
[●] PyTorch CPU (300MB) [Download]
|
||||
✓ Works on any system
|
||||
✗ Slower inference
|
||||
|
||||
[ ] MLX (800MB) [Download]
|
||||
✓ Fast on Apple Silicon
|
||||
✗ macOS only (M1/M2/M3)
|
||||
|
||||
[ ] Remote Server
|
||||
URL: ___________________
|
||||
|
||||
[ ] OpenAI API
|
||||
API Key: ________________
|
||||
```
|
||||
|
||||
4. User selects provider → downloads with progress bar
|
||||
5. Provider installs to AppData/Application Support
|
||||
6. App starts provider → ready to use
|
||||
|
||||
---
|
||||
|
||||
### App Update Flow (No Provider Change)
|
||||
|
||||
**Scenario:** Bug fix in UI, no backend changes
|
||||
|
||||
1. User gets update notification: "Voicebox v0.2.1 available"
|
||||
2. Downloads update (~150MB, not 2.4GB!)
|
||||
3. Installs and restarts
|
||||
4. **Provider stays the same** (no re-download needed)
|
||||
5. App starts using existing provider
|
||||
|
||||
**User experience:** Fast updates, no multi-GB downloads
|
||||
|
||||
---
|
||||
|
||||
### Provider Update Flow
|
||||
|
||||
**Scenario:** New Qwen3-TTS model version released
|
||||
|
||||
1. User opens Settings → Provider tab
|
||||
2. Sees notification: "Provider update available (v1.1.0)"
|
||||
3. Clicks "Update Provider"
|
||||
4. Downloads new provider binary
|
||||
5. Old provider binary is replaced
|
||||
6. Restart app to use new provider
|
||||
|
||||
**Frequency:** Rare (only when TTS model/backend changes)
|
||||
|
||||
---
|
||||
|
||||
### Switching Providers
|
||||
|
||||
**Scenario:** User upgrades to NVIDIA GPU
|
||||
|
||||
1. User goes to Settings → Provider
|
||||
2. Selects "PyTorch CUDA"
|
||||
3. Clicks "Download" → downloads 2.4GB
|
||||
4. Download completes → restarts app
|
||||
5. App now uses CUDA provider
|
||||
|
||||
---
|
||||
|
||||
## Benefits
|
||||
|
||||
| Benefit | Details |
|
||||
| ----------------------------- | --------------------------------------------------------- |
|
||||
| **GitHub Releases Work** | Main app ~150MB << 2GB limit |
|
||||
| **Fast Updates** | UI/feature updates don't require re-downloading providers |
|
||||
| **User Choice** | CPU, CUDA, MLX, OpenAI, remote server |
|
||||
| **External Provider Support** | Users can run their own TTS servers |
|
||||
| **Bandwidth Savings** | Only download provider once, app updates are small |
|
||||
| **Future-Proof** | Easy to add new providers (ElevenLabs, custom models) |
|
||||
| **Team Deployments** | Multiple users share one remote provider |
|
||||
| **Cloud-Ready** | Works with Modal, Replicate, RunPod, etc. |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
### 1. Provider Versioning
|
||||
|
||||
**Question:** Should providers have independent versions or match app version?
|
||||
|
||||
**Options:**
|
||||
|
||||
- A. Independent (providers: v1.x, app: v0.2.x)
|
||||
- B. Matched (both use v0.2.x)
|
||||
|
||||
**Recommendation:** Independent versioning with compatibility matrix
|
||||
|
||||
---
|
||||
|
||||
### 2. Auto-Update Providers
|
||||
|
||||
**Question:** Should providers auto-update separately from app?
|
||||
|
||||
**Options:**
|
||||
|
||||
- A. Manual updates only (user clicks "Update Provider")
|
||||
- B. Optional auto-update (user can enable)
|
||||
- C. Always auto-update
|
||||
|
||||
**Recommendation:** Optional auto-update (default off)
|
||||
|
||||
---
|
||||
|
||||
### 3. Provider Discovery
|
||||
|
||||
**Question:** How does app find installed providers?
|
||||
|
||||
**Options:**
|
||||
|
||||
- A. Check standard paths in AppData/Application Support
|
||||
- B. Registry (Windows) / plist (macOS)
|
||||
- C. Config file with provider locations
|
||||
|
||||
**Recommendation:** Standard paths + config fallback
|
||||
|
||||
---
|
||||
|
||||
### 4. Fallback Behavior
|
||||
|
||||
**Question:** What if no provider is installed?
|
||||
|
||||
**Options:**
|
||||
|
||||
- A. Show setup wizard on first launch
|
||||
- B. Block app until provider installed
|
||||
- C. Allow app to run in "demo mode" (transcription only)
|
||||
|
||||
**Recommendation:** Setup wizard on first launch
|
||||
|
||||
---
|
||||
|
||||
### 5. Provider Auto-Start
|
||||
|
||||
**Question:** Should provider start automatically with app?
|
||||
|
||||
**Options:**
|
||||
|
||||
- A. Always start selected provider on app launch
|
||||
- B. Start on-demand (when user generates speech)
|
||||
- C. User preference
|
||||
|
||||
**Recommendation:** Auto-start (configurable in settings)
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] **Provider Marketplace:** Built-in directory of community providers
|
||||
- [ ] **Multi-Provider Support:** Use different providers per voice/language
|
||||
- [ ] **Provider Health Monitoring:** Automatic failover if provider crashes
|
||||
- [ ] **Cost Tracking:** Monitor API usage for OpenAI/cloud providers
|
||||
- [ ] **Performance Metrics:** Latency, throughput, VRAM usage dashboards
|
||||
- [ ] **Docker Providers:** Run providers in Docker containers
|
||||
- [ ] **Provider Plugins:** Load custom providers from user scripts
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [EXTERNAL_PROVIDERS.md](./EXTERNAL_PROVIDERS.md) - External provider support plan
|
||||
- [OPENAI_SUPPORT.md](./OPENAI_SUPPORT.md) - OpenAI API compatibility
|
||||
- [github-2gb-limit-issue.md](../github-2gb-limit-issue.md) - Original problem
|
||||
- [r2-setup.md](../r2-setup.md) - Cloudflare R2 configuration
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
If you want to build a custom TTS provider:
|
||||
|
||||
1. Implement the provider API spec (see above)
|
||||
2. Test with Voicebox locally
|
||||
3. Package as executable (PyInstaller, Docker, etc.)
|
||||
4. Share in GitHub Discussions
|
||||
|
||||
**Questions?**
|
||||
|
||||
- GitHub Issues: [voicebox/issues](https://github.com/jamiepine/voicebox/issues)
|
||||
- Discord: Coming soon
|
||||
@@ -0,0 +1,191 @@
|
||||
# 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
|
||||
# Chatterbox pins numpy<1.26 / torch==2.6 which break on Python 3.12+
|
||||
{{ pip }} install --no-deps chatterbox-tts
|
||||
# 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"description": "Landing page for voicebox.sh",
|
||||
"scripts": {
|
||||
"dev": "bun --bun next dev --turbo",
|
||||
|
||||
@@ -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}
|
||||
|
||||
+15
-12
@@ -5,7 +5,7 @@ import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Section, SectionTitle } from '@/components/ui/section';
|
||||
import { Section } from '@/components/ui/section';
|
||||
import { DOWNLOAD_LINKS, GITHUB_REPO } from '@/lib/constants';
|
||||
import type { DownloadLinks } from '@/lib/releases';
|
||||
import { FeatureCard } from '../components/ui/feature-card';
|
||||
@@ -39,17 +39,19 @@ export default function Home() {
|
||||
"Powered by Alibaba's Qwen3-TTS model for exceptional voice quality and accuracy.",
|
||||
icon: <Zap className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
title: 'Stories Editor',
|
||||
description:
|
||||
'Create multi-voice narratives with a timeline-based editor. Arrange tracks, trim clips, and mix conversations.',
|
||||
icon: <Code className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
title: 'Multi-Sample Support',
|
||||
description:
|
||||
'Combine multiple voice samples for higher quality and more natural-sounding results.',
|
||||
icon: <Code className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
title: 'Smart Caching',
|
||||
description: 'Instant re-generation with voice prompt caching. No need to reprocess samples.',
|
||||
icon: <Zap className="h-6 w-6" />,
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Local or Remote',
|
||||
description:
|
||||
@@ -237,8 +239,9 @@ export default function Home() {
|
||||
<div className="space-y-6 text-lg text-foreground/80 text-center">
|
||||
<p>
|
||||
Voicebox is a <strong>local-first voice cloning studio</strong> with DAW-like features
|
||||
for professional voice synthesis. Think of it as the <strong>Ollama for voice</strong>{' '}
|
||||
— download models, clone voices, and generate speech entirely on your machine.
|
||||
for professional voice synthesis. Think of it as a{' '}
|
||||
<strong>local, free and open-source alternative to ElevenLabs</strong> — download
|
||||
models, clone voices, and generate speech entirely on your machine.
|
||||
</p>
|
||||
<p>
|
||||
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives
|
||||
@@ -246,6 +249,10 @@ export default function Home() {
|
||||
model, clone any voice from a few seconds of audio, and compose multi-voice projects
|
||||
with studio-grade editing tools.
|
||||
</p>
|
||||
<p>
|
||||
Optimized for performance with <strong>Metal acceleration on Mac</strong> and{' '}
|
||||
<strong>CUDA acceleration on Windows/Linux</strong> for fast, local inference.
|
||||
</p>
|
||||
<p className="text-foreground/60">No Python install required.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,10 +288,6 @@ export default function Home() {
|
||||
|
||||
{/* Features Section */}
|
||||
<Section id="features">
|
||||
<SectionTitle className="mb-4 text-center">Features</SectionTitle>
|
||||
<p className="text-sm text-muted-foreground mb-8 text-center max-w-2xl mx-auto">
|
||||
Everything you need for professional voice cloning in a desktop app.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{features.map((feature) => (
|
||||
<FeatureCard
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "voicebox",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"app",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
uvicorn
|
||||
fastapi
|
||||
sqlalchemy
|
||||
torch
|
||||
torchvision
|
||||
soundfile
|
||||
librosa
|
||||
python-multipart
|
||||
huggingface_hub
|
||||
@@ -6,7 +6,7 @@ set -e
|
||||
echo "Generating OpenAPI client..."
|
||||
|
||||
# Check if backend is running
|
||||
if ! curl -s http://localhost:8000/openapi.json > /dev/null 2>&1; then
|
||||
if ! curl -s http://localhost:17493/openapi.json > /dev/null 2>&1; then
|
||||
echo "Backend not running. Starting backend..."
|
||||
cd backend
|
||||
|
||||
@@ -26,19 +26,19 @@ if ! curl -s http://localhost:8000/openapi.json > /dev/null 2>&1; then
|
||||
|
||||
# Start backend in background
|
||||
echo "Starting backend server..."
|
||||
uvicorn main:app --port 8000 &
|
||||
uvicorn main:app --port 17493 & # Keep the generator on the app's documented local backend port.
|
||||
BACKEND_PID=$!
|
||||
|
||||
# Wait for server to be ready
|
||||
echo "Waiting for server to start..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:8000/openapi.json > /dev/null 2>&1; then
|
||||
for _ in {1..30}; do
|
||||
if curl -s http://localhost:17493/openapi.json > /dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if ! curl -s http://localhost:8000/openapi.json > /dev/null 2>&1; then
|
||||
if ! curl -s http://localhost:17493/openapi.json > /dev/null 2>&1; then
|
||||
echo "Error: Backend failed to start"
|
||||
kill $BACKEND_PID 2>/dev/null || true
|
||||
exit 1
|
||||
@@ -52,7 +52,7 @@ fi
|
||||
|
||||
# Download OpenAPI schema
|
||||
echo "Downloading OpenAPI schema..."
|
||||
curl -s http://localhost:8000/openapi.json > app/openapi.json
|
||||
curl -s http://localhost:17493/openapi.json > app/openapi.json
|
||||
|
||||
# Check if openapi-typescript-codegen is installed
|
||||
if ! bunx --bun openapi-typescript-codegen --version > /dev/null 2>&1; then
|
||||
|
||||
+271
-56
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to observe exactly how HuggingFace reports download progress
|
||||
for each TTS model. Doesn't load models — just downloads and tracks tqdm.
|
||||
|
||||
Usage:
|
||||
backend/venv/bin/python scripts/test_download_progress.py qwen
|
||||
backend/venv/bin/python scripts/test_download_progress.py luxtts
|
||||
backend/venv/bin/python scripts/test_download_progress.py chatterbox
|
||||
|
||||
Add --delete to clear cache first and force a real download:
|
||||
backend/venv/bin/python scripts/test_download_progress.py chatterbox --delete
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from contextlib import contextmanager
|
||||
|
||||
# ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
MODELS = {
|
||||
"qwen": {
|
||||
"repo_id": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
||||
"method": "from_pretrained",
|
||||
"description": "Qwen TTS 1.7B (uses transformers from_pretrained)",
|
||||
},
|
||||
"luxtts": {
|
||||
"repo_id": "YatharthS/LuxTTS",
|
||||
"method": "snapshot_download",
|
||||
"description": "LuxTTS (uses snapshot_download)",
|
||||
},
|
||||
"chatterbox": {
|
||||
"repo_id": "ResembleAI/chatterbox",
|
||||
"method": "snapshot_download",
|
||||
"allow_patterns": [
|
||||
"ve.pt",
|
||||
"t3_mtl23ls_v2.safetensors",
|
||||
"s3gen.pt",
|
||||
"grapheme_mtl_merged_expanded_v1.json",
|
||||
"conds.pt",
|
||||
"Cangjie5_TC.json",
|
||||
],
|
||||
"description": "Chatterbox Multilingual (uses snapshot_download with allow_patterns)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── Progress tracking (mirrors our HFProgressTracker) ────────────────────────
|
||||
|
||||
class ProgressSpy:
|
||||
"""Intercepts tqdm to see exactly what HF reports."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self.events = [] # List of dicts: {time, type, ...}
|
||||
self._original_tqdm_class = None
|
||||
self._original_tqdm_auto = None
|
||||
self._patched_modules = {}
|
||||
self._hf_tqdm_original_update = None
|
||||
self._start_time = None
|
||||
|
||||
def _elapsed(self):
|
||||
return time.time() - self._start_time if self._start_time else 0
|
||||
|
||||
def _log(self, event_type, **kwargs):
|
||||
entry = {"time": f"{self._elapsed():.1f}s", "type": event_type, **kwargs}
|
||||
self.events.append(entry)
|
||||
|
||||
# Live print
|
||||
parts = [f"[{entry['time']:>7s}] {event_type:>10s}"]
|
||||
for k, v in kwargs.items():
|
||||
if k in ("current", "total") and isinstance(v, (int, float)) and v > 1_000_000:
|
||||
parts.append(f"{k}={v / 1_000_000:.1f}MB")
|
||||
else:
|
||||
parts.append(f"{k}={v}")
|
||||
print(" ".join(parts), flush=True)
|
||||
|
||||
def _create_tracked_tqdm_class(self):
|
||||
spy = self
|
||||
original_tqdm = self._original_tqdm_class
|
||||
|
||||
class SpyTqdm(original_tqdm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
desc = kwargs.get("desc", "")
|
||||
if not desc and args:
|
||||
first_arg = args[0]
|
||||
if isinstance(first_arg, str):
|
||||
desc = first_arg
|
||||
|
||||
filename = ""
|
||||
if desc:
|
||||
if ":" in desc:
|
||||
filename = desc.split(":")[0].strip()
|
||||
else:
|
||||
filename = desc.strip()
|
||||
|
||||
# Filter out non-standard kwargs
|
||||
tqdm_kwargs = {
|
||||
'iterable', 'desc', 'total', 'leave', 'file', 'ncols',
|
||||
'mininterval', 'maxinterval', 'miniters', 'ascii', 'disable',
|
||||
'unit', 'unit_scale', 'dynamic_ncols', 'smoothing',
|
||||
'bar_format', 'initial', 'position', 'postfix',
|
||||
'unit_divisor', 'write_bytes', 'lock_args', 'nrows',
|
||||
'colour', 'color', 'delay', 'gui', 'disable_default', 'pos',
|
||||
}
|
||||
filtered_kwargs = {k: v for k, v in kwargs.items() if k in tqdm_kwargs}
|
||||
|
||||
try:
|
||||
super().__init__(*args, **filtered_kwargs)
|
||||
except TypeError:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._spy_filename = filename or "unknown"
|
||||
total = getattr(self, "total", None)
|
||||
|
||||
spy._log(
|
||||
"INIT",
|
||||
filename=self._spy_filename,
|
||||
total=total or 0,
|
||||
unit=kwargs.get("unit", "?"),
|
||||
unit_scale=kwargs.get("unit_scale", False),
|
||||
disable=kwargs.get("disable", False),
|
||||
)
|
||||
|
||||
def update(self, n=1):
|
||||
result = super().update(n)
|
||||
|
||||
current = getattr(self, "n", 0)
|
||||
total = getattr(self, "total", 0)
|
||||
filename = self._spy_filename
|
||||
|
||||
spy._log(
|
||||
"UPDATE",
|
||||
filename=filename,
|
||||
n=n,
|
||||
current=current,
|
||||
total=total or 0,
|
||||
pct=f"{100 * current / total:.1f}%" if total else "?",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def close(self):
|
||||
spy._log("CLOSE", filename=self._spy_filename)
|
||||
return super().close()
|
||||
|
||||
return SpyTqdm
|
||||
|
||||
@contextmanager
|
||||
def patch(self):
|
||||
"""Context manager that patches tqdm globally — same as HFProgressTracker."""
|
||||
self._start_time = time.time()
|
||||
|
||||
try:
|
||||
import tqdm as tqdm_module
|
||||
self._original_tqdm_class = tqdm_module.tqdm
|
||||
except ImportError:
|
||||
yield
|
||||
return
|
||||
|
||||
tracked_tqdm = self._create_tracked_tqdm_class()
|
||||
|
||||
# Patch tqdm.tqdm
|
||||
tqdm_module.tqdm = tracked_tqdm
|
||||
|
||||
# Patch tqdm.auto.tqdm
|
||||
self._original_tqdm_auto = None
|
||||
if hasattr(tqdm_module, "auto") and hasattr(tqdm_module.auto, "tqdm"):
|
||||
self._original_tqdm_auto = tqdm_module.auto.tqdm
|
||||
tqdm_module.auto.tqdm = tracked_tqdm
|
||||
|
||||
# Patch in sys.modules (same as HFProgressTracker)
|
||||
tqdm_attr_names = ['tqdm', 'base_tqdm', 'old_tqdm']
|
||||
patched_count = 0
|
||||
|
||||
for module_name in list(sys.modules.keys()):
|
||||
if "huggingface" in module_name or module_name.startswith("tqdm"):
|
||||
try:
|
||||
module = sys.modules[module_name]
|
||||
for attr_name in tqdm_attr_names:
|
||||
if hasattr(module, attr_name):
|
||||
attr = getattr(module, attr_name)
|
||||
is_tqdm_class = (
|
||||
attr is self._original_tqdm_class
|
||||
or (self._original_tqdm_auto and attr is self._original_tqdm_auto)
|
||||
or (
|
||||
hasattr(attr, "__name__")
|
||||
and attr.__name__ == "tqdm"
|
||||
and hasattr(attr, "update")
|
||||
)
|
||||
)
|
||||
if is_tqdm_class:
|
||||
key = f"{module_name}.{attr_name}"
|
||||
self._patched_modules[key] = (module, attr_name, attr)
|
||||
setattr(module, attr_name, tracked_tqdm)
|
||||
patched_count += 1
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
# Monkey-patch HF's tqdm.update (same as HFProgressTracker)
|
||||
try:
|
||||
from huggingface_hub.utils import tqdm as hf_tqdm_module
|
||||
if hasattr(hf_tqdm_module, 'tqdm'):
|
||||
hf_tqdm_class = hf_tqdm_module.tqdm
|
||||
self._hf_tqdm_original_update = hf_tqdm_class.update
|
||||
spy = self
|
||||
|
||||
def patched_update(tqdm_self, n=1):
|
||||
result = spy._hf_tqdm_original_update(tqdm_self, n)
|
||||
desc = getattr(tqdm_self, 'desc', '') or ''
|
||||
current = getattr(tqdm_self, 'n', 0)
|
||||
total = getattr(tqdm_self, 'total', 0) or 0
|
||||
|
||||
spy._log(
|
||||
"HF_UPDATE",
|
||||
desc=desc,
|
||||
current=current,
|
||||
total=total,
|
||||
pct=f"{100 * current / total:.1f}%" if total else "?",
|
||||
)
|
||||
return result
|
||||
|
||||
hf_tqdm_class.update = patched_update
|
||||
patched_count += 1
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
print(f"\n=== Patched {patched_count} tqdm references ===\n", flush=True)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Restore everything
|
||||
import tqdm as tqdm_module
|
||||
tqdm_module.tqdm = self._original_tqdm_class
|
||||
if self._original_tqdm_auto:
|
||||
tqdm_module.auto.tqdm = self._original_tqdm_auto
|
||||
for key, (module, attr_name, original) in self._patched_modules.items():
|
||||
try:
|
||||
setattr(module, attr_name, original)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
if self._hf_tqdm_original_update:
|
||||
try:
|
||||
from huggingface_hub.utils import tqdm as hf_tqdm_module
|
||||
if hasattr(hf_tqdm_module, 'tqdm'):
|
||||
hf_tqdm_module.tqdm.update = self._hf_tqdm_original_update
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
def summary(self):
|
||||
print("\n" + "=" * 70)
|
||||
print("SUMMARY")
|
||||
print("=" * 70)
|
||||
|
||||
inits = [e for e in self.events if e["type"] == "INIT"]
|
||||
updates = [e for e in self.events if e["type"] in ("UPDATE", "HF_UPDATE")]
|
||||
|
||||
print(f"\ntqdm bars created: {len(inits)}")
|
||||
for e in inits:
|
||||
print(f" - {e.get('filename', '?'):40s} total={e.get('total', '?')}")
|
||||
|
||||
print(f"\nTotal update calls: {len(updates)}")
|
||||
|
||||
# Group updates by filename
|
||||
by_file = {}
|
||||
for e in updates:
|
||||
fn = e.get("filename") or e.get("desc", "unknown")
|
||||
if fn not in by_file:
|
||||
by_file[fn] = []
|
||||
by_file[fn].append(e)
|
||||
|
||||
for fn, evts in by_file.items():
|
||||
max_current = max(e.get("current", 0) for e in evts)
|
||||
max_total = max(e.get("total", 0) for e in evts)
|
||||
print(f"\n {fn}:")
|
||||
print(f" updates: {len(evts)}")
|
||||
print(f" max current: {max_current:,}")
|
||||
print(f" max total: {max_total:,}")
|
||||
if max_total > 0 and max_current > 0:
|
||||
print(f" final pct: {100 * max_current / max_total:.1f}%")
|
||||
else:
|
||||
print(f" final pct: NO PROGRESS REPORTED")
|
||||
|
||||
|
||||
# ─── Delete cache ─────────────────────────────────────────────────────────────
|
||||
|
||||
def delete_cache(repo_id: str):
|
||||
from huggingface_hub import constants as hf_constants
|
||||
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||
repo_cache = cache_dir / ("models--" + repo_id.replace("/", "--"))
|
||||
if repo_cache.exists():
|
||||
print(f"Deleting cache: {repo_cache}")
|
||||
shutil.rmtree(repo_cache)
|
||||
print("Deleted.")
|
||||
else:
|
||||
print(f"No cache found at {repo_cache}")
|
||||
|
||||
|
||||
# ─── Download functions ───────────────────────────────────────────────────────
|
||||
|
||||
def download_qwen(spy: ProgressSpy):
|
||||
"""Mirrors how pytorch_backend.py downloads Qwen."""
|
||||
from transformers import AutoModel
|
||||
repo_id = MODELS["qwen"]["repo_id"]
|
||||
|
||||
print(f"Downloading {repo_id} via AutoModel.from_pretrained...")
|
||||
with spy.patch():
|
||||
# This is what Qwen3TTSModel.from_pretrained does under the hood
|
||||
from huggingface_hub import snapshot_download
|
||||
snapshot_download(repo_id)
|
||||
|
||||
|
||||
def download_luxtts(spy: ProgressSpy):
|
||||
"""Mirrors how luxtts_backend.py downloads LuxTTS."""
|
||||
from huggingface_hub import snapshot_download
|
||||
repo_id = MODELS["luxtts"]["repo_id"]
|
||||
|
||||
print(f"Downloading {repo_id} via snapshot_download...")
|
||||
with spy.patch():
|
||||
snapshot_download(repo_id)
|
||||
|
||||
|
||||
def download_chatterbox(spy: ProgressSpy):
|
||||
"""Mirrors how chatterbox_backend.py downloads Chatterbox."""
|
||||
from huggingface_hub import snapshot_download
|
||||
cfg = MODELS["chatterbox"]
|
||||
|
||||
print(f"Downloading {cfg['repo_id']} via snapshot_download with allow_patterns...")
|
||||
with spy.patch():
|
||||
snapshot_download(
|
||||
repo_id=cfg["repo_id"],
|
||||
repo_type="model",
|
||||
revision="main",
|
||||
allow_patterns=cfg["allow_patterns"],
|
||||
token=os.getenv("HF_TOKEN"),
|
||||
)
|
||||
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in MODELS:
|
||||
print(f"Usage: {sys.argv[0]} <{'|'.join(MODELS.keys())}> [--delete]")
|
||||
sys.exit(1)
|
||||
|
||||
model_key = sys.argv[1]
|
||||
should_delete = "--delete" in sys.argv
|
||||
cfg = MODELS[model_key]
|
||||
|
||||
print(f"\n{'=' * 70}")
|
||||
print(f"Testing download progress for: {cfg['description']}")
|
||||
print(f"Repo: {cfg['repo_id']}")
|
||||
print(f"Method: {cfg['method']}")
|
||||
print(f"{'=' * 70}\n")
|
||||
|
||||
if should_delete:
|
||||
delete_cache(cfg["repo_id"])
|
||||
print()
|
||||
|
||||
spy = ProgressSpy()
|
||||
|
||||
dispatch = {
|
||||
"qwen": download_qwen,
|
||||
"luxtts": download_luxtts,
|
||||
"chatterbox": download_chatterbox,
|
||||
}
|
||||
|
||||
try:
|
||||
dispatch[model_key](spy)
|
||||
except Exception as e:
|
||||
print(f"\n!!! Download failed: {e}")
|
||||
|
||||
spy.summary()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+6
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@voicebox/tauri",
|
||||
"private": true,
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -10,7 +10,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0"
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
||||
Generated
+2
-1
@@ -5041,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "voicebox"
|
||||
version = "0.1.9"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"core-foundation-sys",
|
||||
@@ -5064,6 +5064,7 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"wasapi",
|
||||
"webkit2gtk",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "voicebox"
|
||||
version = "0.1.10"
|
||||
version = "0.1.13"
|
||||
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
@@ -37,6 +37,9 @@ core-foundation-sys = "0.8"
|
||||
wasapi = "0.22"
|
||||
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_Com"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = "2.0"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2.0"
|
||||
tauri-plugin-process = "2.0"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user