Compare commits

..
Author SHA1 Message Date
Jamie Pine 83906c6c4b Fix R2 bucket name: voicebox (not voicebox-releases) 2026-01-31 00:47:18 -08:00
Jamie Pine 65f132e9c2 Integrate Cloudflare R2 for CUDA binary hosting
- Update CI workflow to upload CUDA binary to R2 instead of GitHub
- Use custom domain: downloads.voicebox.sh
- Update release notes with R2 download link
- Add R2 setup documentation with GitHub secrets instructions
- Add local test script for R2 uploads
- Fixes GitHub 2GB release asset limit issue

Required GitHub secrets:
- R2_ACCESS_KEY_ID
- R2_SECRET_ACCESS_KEY
- R2_ENDPOINT

Cost: ~$0.04/month (free bandwidth with R2)
2026-01-31 00:46:50 -08:00
Jamie Pine c211e52382 Add comprehensive CUDA distribution problem analysis
- Documents entire problem from 3GB installer to GitHub 2GB limit
- Analyzes compression test failure (1% reduction)
- Compares 7 different hosting options
- Cost analysis for each approach
- Recommends Cloudflare R2 (free egress, ~/usr/bin/bash.04/month)
- Technical implementation details for all options
- Complete research document for decision making
2026-01-31 00:40:52 -08:00
Jamie Pine 7c093130c6 Fix unicode encoding in compression test script 2026-01-31 00:36:03 -08:00
Jamie Pine 8ffd5bc008 Add CUDA binary compression test script
GitHub has a 2GB limit on release assets, but the CUDA binary is ~2.5GB. Added compression test script to check if 7z can get it under the limit. If not, we'll need external hosting (S3/Azure).
2026-01-30 23:37:29 -08:00
Jamie PineandClaude Sonnet 4.5 2542f64e1b Implement dual server binary system (CPU/CUDA)
Problem: The server binary with CUDA support was 2.9GB, causing:
- MSI installer failures in CI (WiX can't handle 3GB files)
- Massive downloads for all users (even those without GPUs)
- Poor user experience

Solution: Build two separate server binaries:
- voicebox-server.exe (CPU-only, ~295MB) - ships with installer
- voicebox-server-cuda.exe (CUDA, ~2.9GB) - optional download

Changes:
- backend/build_binary.py: Added 'variant' parameter for CPU/CUDA builds
- backend/build_cpu.bat: Script to build CPU-only binary
- backend/build_cuda.bat: Script to build CUDA binary
- backend/build_both.bat: Script to build both binaries
- backend/build_cpu.sh: Unix build script for CPU binary
- .github/workflows/release.yml: Build both variants, upload CUDA separately
- tauri/vite.config.ts: Externalize Tauri plugins to fix build
- docs/dual-server-binaries.md: Complete documentation

Results:
- Installer size reduced from 3GB to ~500MB (6x smaller)
- CI builds now succeed (WiX can handle 500MB)
- GPU users can opt-in to download CUDA support
- Better bandwidth usage for CPU-only users

Next steps:
- Frontend implementation to detect GPU and download CUDA binary
- Settings UI to toggle between CPU/CUDA modes

Co-Authored-By: Claude Sonnet 4.5 (1M context) <[email protected]>
2026-01-30 22:51:43 -08:00
133 changed files with 2752 additions and 11622 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.1.13
current_version = 0.1.12
commit = True
tag = True
tag_name = v{new_version}
-46
View File
@@ -1,46 +0,0 @@
# Version control
.git
.github
.gitignore
# Desktop-only (not needed in web container)
tauri/
landing/
docs/
mlx-test/
scripts/
# Dependencies & build artifacts (rebuilt in Docker)
node_modules/
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
*.spec
# Data (will be bind-mounted)
data/
backend/data/
# IDE & OS
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
# Config files not needed in container
biome.json
.biomeignore
.bumpversion.cfg
.npmrc
Makefile
CHANGELOG.md
CONTRIBUTING.md
SECURITY.md
LICENSE
README.md
backend/README.md
-73
View File
@@ -1,73 +0,0 @@
name: Build CUDA Backend
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
build-cuda-windows:
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
- name: Install PyTorch with CUDA 12.1
run: |
pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall --no-deps
pip install torchaudio --index-url https://download.pytorch.org/whl/cu121
- name: Verify CUDA support in torch
run: |
python -c "import torch; print(f'CUDA available in build: {torch.cuda.is_available()}'); print(f'CUDA version: {torch.version.cuda}')"
- name: Build CUDA server binary
shell: bash
working-directory: backend
run: python build_binary.py --cuda
- name: Split binary for GitHub Releases
shell: bash
run: |
python scripts/split_binary.py \
backend/dist/voicebox-server-cuda.exe \
--output release-assets/
- name: Upload split parts to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: |
release-assets/voicebox-server-cuda.part*.exe
release-assets/voicebox-server-cuda.sha256
release-assets/voicebox-server-cuda.manifest
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload binary as workflow artifact (for testing)
uses: actions/upload-artifact@v4
with:
name: voicebox-server-cuda-windows
path: backend/dist/voicebox-server-cuda.exe
retention-days: 7
# Linux CUDA build can be added later with:
# build-cuda-linux:
# runs-on: ubuntu-22.04
# ...
-63
View File
@@ -1,63 +0,0 @@
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
+83 -31
View File
@@ -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: "ubuntu-22.04"
args: ""
python-version: "3.12"
backend: "pytorch"
- platform: "windows-latest"
args: ""
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'
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,24 +66,24 @@ 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: |
chmod +x scripts/build-server.sh
./scripts/build-server.sh
- name: Build Python server (Windows)
- name: Build CPU Python server (Windows)
if: matrix.platform == 'windows-latest'
shell: bash
run: |
cd backend
python build_binary.py
echo "Installing CPU-only PyTorch..."
pip uninstall -y torch torchvision torchaudio
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
echo "Building CPU server binary..."
python build_binary.py cpu
# Get platform tuple
PLATFORM=$(rustc --print host-tuple)
@@ -91,9 +91,31 @@ jobs:
# Create binaries directory
mkdir -p ../tauri/src-tauri/binaries
# Copy with platform suffix
# Copy CPU version (default for installer)
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
echo "Built voicebox-server-${PLATFORM}.exe"
echo "Built CPU server: voicebox-server-${PLATFORM}.exe (~500MB)"
- name: Build CUDA Python server (Windows)
if: matrix.platform == 'windows-latest'
shell: bash
run: |
cd backend
echo "Installing CUDA PyTorch..."
pip uninstall -y torch torchvision torchaudio
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
echo "Building CUDA server binary..."
python build_binary.py cuda
# Get platform tuple
PLATFORM=$(rustc --print host-tuple)
# Copy CUDA version for separate upload
mkdir -p cuda-release
cp dist/voicebox-server-cuda.exe cuda-release/voicebox-server-cuda-${PLATFORM}.exe
echo "Built CUDA server: voicebox-server-cuda-${PLATFORM}.exe (~3GB)"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -106,7 +128,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
@@ -142,7 +164,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.
@@ -150,11 +172,41 @@ jobs:
### Installation
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference
- **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch
- **Windows**: Download the `.msi` installer
- **Windows**: Download the `.msi` installer - includes CPU-only inference (~500MB)
- **Linux**: Download the `.AppImage` or `.deb` package
### NVIDIA GPU Acceleration (Windows)
Windows users with NVIDIA GPUs can enable CUDA for 4-5x faster inference:
1. Install the app normally (CPU version included in installer)
2. The app will detect your GPU and offer to download CUDA support automatically
3. Or manually download: [voicebox-server-cuda-x86_64-pc-windows-msvc.exe](https://downloads.voicebox.sh/cuda/__VERSION__/voicebox-server-cuda-x86_64-pc-windows-msvc.exe) (~2.4GB)
The app includes automatic updates - future updates will be installed automatically.
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
includeUpdaterJson: true
- name: Upload CUDA server to Cloudflare R2 (Windows only)
if: matrix.platform == 'windows-latest'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
run: |
# Install AWS CLI if not available
pip install awscli
# Get version from tag
VERSION=${GITHUB_REF#refs/tags/}
# Get platform tuple
PLATFORM=$(rustc --print host-tuple)
# Upload to R2
aws s3 cp backend/cuda-release/voicebox-server-cuda-${PLATFORM}.exe \
s3://voicebox/cuda/${VERSION}/voicebox-server-cuda-${PLATFORM}.exe \
--endpoint-url $R2_ENDPOINT \
--acl public-read
echo "CUDA binary uploaded to: https://downloads.voicebox.sh/cuda/${VERSION}/voicebox-server-cuda-${PLATFORM}.exe"
-12
View File
@@ -5,14 +5,6 @@ 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
@@ -61,10 +53,6 @@ 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
+2 -22
View File
@@ -27,32 +27,12 @@ Thank you for your interest in contributing to Voicebox! This document provides
```bash
rustc --version # Check if installed
```
- **[Tauri Prerequisites](https://v2.tauri.app/start/prerequisites)** - Tauri-specific system dependencies (varies by OS).
- **Git** - Version control
### Development Setup
**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.
**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.
**Manual setup (required for Windows):**
@@ -427,7 +407,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:17493/openapi.json`
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:8000/openapi.json`
## Questions?
-79
View File
@@ -1,79 +0,0 @@
# ============================================================
# Voicebox — Local TTS Server with Web UI (CPU)
# 3-stage build: Frontend → Python deps → Runtime
# ============================================================
# === Stage 1: Build frontend ===
FROM oven/bun:1 AS frontend
WORKDIR /build
# Copy workspace config and frontend source
COPY package.json bun.lock ./
COPY app/ ./app/
COPY web/ ./web/
# Strip workspaces not needed for web build, and fix trailing comma
RUN sed -i '/"tauri"/d; /"landing"/d' package.json && \
sed -i -z 's/,\n ]/\n ]/' package.json
RUN bun install --no-save
# Build frontend (skip tsc — upstream has pre-existing type errors)
RUN cd web && bunx --bun vite build
# === Stage 2: Build Python dependencies ===
FROM python:3.11-slim AS backend-builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
RUN pip install --no-cache-dir --prefix=/install \
git+https://github.com/QwenLM/Qwen3-TTS.git
# === Stage 3: Runtime ===
FROM python:3.11-slim
# Create non-root user for security
RUN groupadd -r voicebox && \
useradd -r -g voicebox -m -s /bin/bash voicebox
WORKDIR /app
# Install only runtime system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy installed Python packages from builder stage
COPY --from=backend-builder /install /usr/local
# Copy backend application code
COPY --chown=voicebox:voicebox backend/ /app/backend/
# Copy built frontend from frontend stage
COPY --from=frontend --chown=voicebox:voicebox /build/web/dist /app/frontend/
# Create data directories owned by non-root user
RUN mkdir -p /app/data/generations /app/data/profiles /app/data/cache \
&& chown -R voicebox:voicebox /app/data
# Switch to non-root user
USER voicebox
# Expose the API port
EXPOSE 17493
# Health check — auto-restart if the server hangs
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
CMD curl -f http://localhost:17493/health || exit 1
# Start the FastAPI server
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
+1 -6
View File
@@ -48,7 +48,6 @@ 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; \
@@ -80,11 +79,7 @@ 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 && if [ "$$(uname)" = "Linux" ] && lspci 2>/dev/null | grep -qi nvidia; then \
WEBKIT_DISABLE_DMABUF_RENDERER=1 $(MAKE) dev-frontend; \
else \
$(MAKE) dev-frontend; \
fi & \
sleep 2 && $(MAKE) dev-frontend & \
wait
dev-backend: ## Start FastAPI backend server
-58
View File
@@ -1,58 +0,0 @@
# Voicebox Offline Mode Fix
## Problem
Voicebox crashes when generating speech if HuggingFace is unreachable, even when models are fully cached locally.
**Root Cause:**
- Voicebox downloads `mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16` (MLX optimized version)
- But `mlx_audio.tts.load()` tries to fetch `config.json` from original repo `Qwen/Qwen3-TTS-12Hz-1.7B-Base`
- This network request fails → server crashes with `RemoteDisconnected`
**Related Issues:**
- Issue #150: "Internet connection required, even though models are downloaded?"
- Issue #151: "API Stability Issues: Model Loading Hangs and Server Crashes"
## Solution
Two-part fix:
### 1. Monkey-patch huggingface_hub (`backend/utils/hf_offline_patch.py`)
- Intercepts cache lookup functions
- Forces offline mode early (before mlx_audio imports)
- Adds debug logging for cache hits/misses
### 2. Symlink original repo to MLX version (`ensure_original_qwen_config_cached()`)
- When original `Qwen/Qwen3-TTS-12Hz-1.7B-Base` cache doesn't exist
- But MLX `mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16` does exist
- Creates a symlink so cache lookups succeed
## Files Changed
- `backend/backends/mlx_backend.py` - Added patch imports at top
- `backend/utils/hf_offline_patch.py` - New patch module
## Testing
To test this fix:
1. Build Voicebox from source: `make build`
2. Disconnect from internet
3. Try generating speech
4. Should work without network requests
## Build Instructions
```bash
# Install dependencies
pip install -r requirements.txt
# Build the app
make build
# Or build just the server
make build-server
```
## Notes
- The patch is applied automatically when `mlx_backend.py` is imported
- Set `VOICEBOX_OFFLINE_PATCH=0` to disable the patch
- The symlink approach works because the config.json is compatible between versions
---
*Patch contributed by community*
+38 -20
View File
@@ -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 a **local, free and open-source alternative to ElevenLabs** — 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 the **Ollama for voice** — download models, clone voices, and generate speech entirely on your machine.
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives you:
@@ -80,10 +80,10 @@ Voicebox is available now for macOS and Windows.
| Platform | Download |
|----------|----------|
| macOS (Apple Silicon) | [Voicebox_aarch64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/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) |
| 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) |
> **Linux builds coming soon** — Currently blocked by GitHub runner disk space limitations.
@@ -98,12 +98,12 @@ 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
- **Lightning fast on Mac** — MLX backend leverages Apple Silicon's Neural Engine for super fast generation
### Voice Profile Management
- **Create profiles** from audio files or record directly in-app
- **Import/Export** profiles to share or back up
- **Import/Export** profiles to share or backup
- **Multi-sample support** — combine multiple samples for higher quality cloning
- **Organize** with descriptions and language tags
@@ -147,20 +147,17 @@ 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:17493/generate \
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
# List voice profiles
curl http://localhost:17493/profiles
curl http://localhost:8000/profiles
# Create a profile
curl -X POST http://localhost:17493/profiles \
curl -X POST http://localhost:8000/profiles \
-H "Content-Type: application/json" \
-d '{"name": "My Voice", "language": "en"}'
```
@@ -173,7 +170,7 @@ curl -X POST http://localhost:17493/profiles \
- Voice assistants
- Content creation automation
Full API documentation is available at `http://localhost:17493/docs` in the default local workflow, or at `/docs` on whatever server address you configured.
Full API documentation available at `http://localhost:8000/docs` when running.
---
@@ -228,21 +225,42 @@ 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
git clone https://github.com/jamiepine/voicebox.git
# Clone the repo
git clone https://github.com/voicebox-sh/voicebox.git
cd voicebox
just setup # creates Python venv, installs all deps
just dev # starts backend + desktop app
# Setup everything
make setup
# Start development
make dev
```
Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands.
**Manual setup (all platforms):**
Also available via Makefile: `make setup && make dev` (run `make help` for all commands).
```bash
# Clone the repo
git clone https://github.com/voicebox-sh/voicebox.git
cd voicebox
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [XCode on macOS](https://developer.apple.com/xcode/), [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/).
# 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).
**Performance:**
- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration for 4-5x faster inference
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@voicebox/app",
"version": "0.1.13",
"version": "0.1.12",
"private": true,
"type": "module",
"scripts": {
+2 -4
View File
@@ -93,12 +93,10 @@ function App() {
}
serverStartingRef.current = true;
const isRemote = useServerStore.getState().mode === 'remote';
const customModelsDir = useServerStore.getState().customModelsDir;
console.log(`Production mode: Starting bundled server... (remote: ${isRemote})`);
console.log('Production mode: Starting bundled server...');
platform.lifecycle
.startServer(isRemote, customModelsDir)
.startServer(false)
.then((serverUrl) => {
console.log('Server is ready at:', serverUrl);
// Update the server URL in the store with the dynamically assigned port
+6 -26
View File
@@ -1,18 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState } from '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 { formatAudioDuration } from '@/lib/utils/audio';
import { debug } from '@/lib/utils/debug';
import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
import { usePlatform } from '@/platform/PlatformContext';
export function AudioPlayer() {
const platform = usePlatform();
const volumeLabelId = useId();
const {
audioUrl,
audioId,
@@ -360,7 +359,7 @@ export function AudioPlayer() {
if (shouldAutoPlayNow) {
// Clear the flag first
usePlayerStore.getState().clearAutoPlayFlag();
// Use a small delay to ensure audio element is fully ready
setTimeout(() => {
wavesurfer.play().catch((error) => {
@@ -665,7 +664,7 @@ export function AudioPlayer() {
// Handle shouldAutoPlay flag - for story mode auto-advance
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
useEffect(() => {
const wavesurfer = wavesurferRef.current;
if (!wavesurfer || !shouldAutoPlay || duration === 0) {
@@ -832,9 +831,6 @@ export function AudioPlayer() {
disabled={isLoading || duration === 0}
className="shrink-0"
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
aria-label={
duration === 0 && !isLoading ? 'Audio not loaded' : isPlaying ? 'Pause' : 'Play'
}
>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</Button>
@@ -849,8 +845,6 @@ export function AudioPlayer() {
max={100}
step={0.1}
className="w-full"
aria-label="Playback position"
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
/>
)}
{isLoading && (
@@ -868,9 +862,7 @@ export function AudioPlayer() {
{/* Title */}
{title && (
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block">
{title}
</div>
<div className="text-sm font-medium truncate max-w-[200px] shrink-0">{title}</div>
)}
{/* Loop Button */}
@@ -880,37 +872,26 @@ export function AudioPlayer() {
onClick={toggleLoop}
className={isLooping ? 'text-primary' : ''}
title="Toggle loop"
aria-label={isLooping ? 'Stop looping' : 'Loop'}
>
<Repeat className="h-4 w-4" />
</Button>
{/* Volume Control */}
<div
className="flex items-center gap-2 shrink-0 w-[120px]"
role="group"
aria-label="Volume"
>
<div className="flex items-center gap-2 shrink-0 w-[120px]">
<Button
variant="ghost"
size="icon"
onClick={() => setVolume(volume > 0 ? 0 : 1)}
className="h-8 w-8"
aria-label={volume > 0 ? 'Mute' : 'Unmute'}
>
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
</Button>
<span id={volumeLabelId} className="sr-only">
Volume level, {Math.round(volume * 100)}%
</span>
<Slider
value={[volume * 100]}
onValueChange={handleVolumeChange}
max={100}
step={1}
className="flex-1"
aria-labelledby={volumeLabelId}
aria-valuetext={`${Math.round(volume * 100)}%`}
/>
</div>
@@ -921,7 +902,6 @@ export function AudioPlayer() {
onClick={handleClose}
className="shrink-0"
title="Close player"
aria-label="Close player"
>
<X className="h-5 w-5" />
</Button>
+9 -13
View File
@@ -23,8 +23,8 @@ import {
import { apiClient } from '@/lib/api/client';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
import { usePlatform } from '@/platform/PlatformContext';
interface AudioDevice {
id: string;
@@ -124,13 +124,6 @@ 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
@@ -168,7 +161,7 @@ export function AudioTab() {
</Button>
</div>
) : (
<div className="space-y-3">
<div className="space-y-3 p-2">
{allChannels.map((channel) => {
const isSelected = selectedChannelId === channel.id;
return (
@@ -248,7 +241,12 @@ export function AudioTab() {
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => handleChannelDelete(e, channel.id)}
onClick={(e) => {
e.stopPropagation();
if (confirm('Delete this channel?')) {
deleteChannel.mutate(channel.id);
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -343,9 +341,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">
{platform.metadata.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, SlidersHorizontal, Sparkles } from 'lucide-react';
import { Loader2, MessageSquare, 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';
@@ -12,15 +12,14 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useToast } from '@/components/ui/use-toast';
import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
import { useStory } from '@/lib/hooks/useStories';
import { useAddStoryItem, useStory } from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore';
import { useUIStore } from '@/stores/uiStore';
import { ParalinguisticInput } from './ParalinguisticInput';
interface FloatingGenerateBoxProps {
isPlayerOpen?: boolean;
@@ -44,7 +43,8 @@ export function FloatingGenerateBox({
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
const { data: currentStory } = useStory(selectedStoryId);
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
const addStoryItem = useAddStoryItem();
const { toast } = useToast();
// Calculate if track editor is visible (on stories route with items)
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
@@ -52,9 +52,25 @@ export function FloatingGenerateBox({
const { form, handleSubmit, isPending } = useGenerationForm({
onSuccess: async (generationId) => {
setIsExpanded(false);
// Defer the story add until TTS completes — useGenerationProgress handles it
// If on stories route and a story is selected, add generation to story
if (isStoriesRoute && selectedStoryId && generationId) {
addPendingStoryAdd(generationId, selectedStoryId);
try {
await addStoryItem.mutateAsync({
storyId: selectedStoryId,
data: { generation_id: generationId },
});
toast({
title: 'Added to story',
description: `Generation added to "${currentStory?.name || 'story'}"`,
});
} catch (error) {
toast({
title: 'Failed to add to story',
description:
error instanceof Error ? error.message : 'Could not add generation to story',
variant: 'destructive',
});
}
}
},
});
@@ -96,13 +112,6 @@ export function FloatingGenerateBox({
}
}, [selectedProfileId, profiles, setSelectedProfileId]);
// Sync generation form language with selected profile's language
useEffect(() => {
if (selectedProfile?.language) {
form.setValue('language', selectedProfile.language as LanguageCode);
}
}, [selectedProfile, form]);
// Auto-resize textarea based on content (only when expanded)
useEffect(() => {
if (!isExpanded) {
@@ -165,7 +174,7 @@ export function FloatingGenerateBox({
isStoriesRoute
? // Position aligned with story list: after sidebar + padding, width 360px
'left-[calc(5rem+2rem)] w-[360px]'
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
)}
style={{
// On stories route: offset by track editor height when visible
@@ -178,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 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 overflow-hidden p-3"
transition={{ duration: 0.6, ease: 'easeInOut' }}
>
<Form {...form}>
@@ -203,57 +212,34 @@ export function FloatingGenerateBox({
transition={{ duration: 0.15, ease: 'easeOut' }}
style={{ overflow: 'hidden' }}
>
{form.watch('engine') === 'chatterbox_turbo' ? (
<ParalinguisticInput
value={field.value}
onChange={field.onChange}
placeholder={
isStoriesRoute && currentStory
? `Generate speech for "${currentStory.name}"... (type / for effects)`
: selectedProfile
? `Type / for effects like [laugh], [sigh]...`
: 'Select a voice profile above...'
<Textarea
{...field}
ref={(node: HTMLTextAreaElement | null) => {
// Store ref for auto-resize (only for active field)
if (!isInstructMode) {
textareaRef.current = node;
}
className="px-3 py-2 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 w-full"
style={{
minHeight: isExpanded ? '100px' : '32px',
maxHeight: '300px',
overflowY: 'auto',
}}
disabled={!selectedProfileId}
onClick={() => setIsExpanded(true)}
onFocus={() => setIsExpanded(true)}
/>
) : (
<Textarea
{...field}
ref={(node: HTMLTextAreaElement | null) => {
// Store ref for auto-resize (only for active field)
if (!isInstructMode) {
textareaRef.current = node;
}
// Forward ref to react-hook-form
if (typeof field.ref === 'function') {
field.ref(node);
}
}}
placeholder={
isStoriesRoute && currentStory
? `Generate speech for "${currentStory.name}"...`
: selectedProfile
? `Generate speech using ${selectedProfile.name}...`
: 'Select a voice profile above...'
// Forward ref to react-hook-form
if (typeof field.ref === 'function') {
field.ref(node);
}
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',
maxHeight: '300px',
}}
disabled={!selectedProfileId}
onClick={() => setIsExpanded(true)}
onFocus={() => setIsExpanded(true)}
/>
)}
}}
placeholder={
isStoriesRoute && currentStory
? `Generate speech for "${currentStory.name}"...`
: selectedProfile
? `Generate speech using ${selectedProfile.name}...`
: 'Select a voice profile above...'
}
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',
maxHeight: '300px',
}}
disabled={!selectedProfileId}
onClick={() => setIsExpanded(true)}
onFocus={() => setIsExpanded(true)}
/>
</motion.div>
</FormControl>
<FormMessage className="text-xs" />
@@ -288,7 +274,7 @@ export function FloatingGenerateBox({
field.ref(node);
}
}}
placeholder="e.g. very happy and excited"
placeholder="Add delivery instructions..."
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',
@@ -308,36 +294,20 @@ export function FloatingGenerateBox({
</motion.div>
<div className="relative shrink-0">
<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"
aria-label={
isPending
? 'Generating...'
: !selectedProfileId
? 'Select a voice profile first'
: 'Generate speech'
}
>
{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>
<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>
<AnimatePresence>
{isExpanded && form.watch('engine') === 'qwen' && (
{isExpanded && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
@@ -345,28 +315,20 @@ export function FloatingGenerateBox({
transition={{ duration: 0.2 }}
className="absolute top-0 right-[calc(100%+0.5rem)]"
>
<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',
)}
aria-label={
isInstructMode ? 'Fine tune instructions, on' : 'Fine tune instructions'
}
>
<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>
<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>
</motion.div>
)}
</AnimatePresence>
@@ -405,86 +367,51 @@ export function FloatingGenerateBox({
<FormField
control={form.control}
name="language"
render={({ field }) => {
const engineLangs = getLanguageOptionsForEngine(
form.watch('engine') || 'qwen',
);
return (
<FormItem className="flex-1 space-y-0">
<Select onValueChange={field.onChange} value={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>
{engineLangs.map((lang) => (
<SelectItem key={lang.value} value={lang.value} className="text-xs">
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage className="text-xs" />
</FormItem>
);
}}
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>
{LANGUAGE_OPTIONS.map((lang) => (
<SelectItem key={lang.value} value={lang.value} className="text-xs">
{lang.label}
</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'
: form.watch('engine') === 'chatterbox_turbo'
? 'chatterbox_turbo'
: `qwen:${form.watch('modelSize') || '1.7B'}`
}
onValueChange={(value) => {
if (value === 'luxtts') {
form.setValue('engine', 'luxtts');
form.setValue('language', 'en');
} else if (value === 'chatterbox') {
form.setValue('engine', 'chatterbox');
} else if (value === 'chatterbox_turbo') {
form.setValue('engine', 'chatterbox_turbo');
form.setValue('language', 'en');
} 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>
<SelectItem
value="chatterbox_turbo"
className="text-xs text-muted-foreground"
>
Chatterbox Turbo
</SelectItem>
</SelectContent>
</Select>
</FormItem>
<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>
)}
/>
</div>
</motion.div>
</AnimatePresence>
+68 -112
View File
@@ -19,11 +19,10 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
import { ParalinguisticInput } from './ParalinguisticInput';
export function GenerationForm() {
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
@@ -65,134 +64,87 @@ export function GenerationForm() {
<FormItem>
<FormLabel>Text to Speak</FormLabel>
<FormControl>
{form.watch('engine') === 'chatterbox_turbo' ? (
<ParalinguisticInput
value={field.value}
onChange={field.onChange}
placeholder="Enter text... type / for effects like [laugh], [sigh]"
className="min-h-[150px] rounded-md border border-input bg-background px-3 py-2"
/>
) : (
<Textarea
placeholder="Enter the text you want to generate..."
className="min-h-[150px]"
{...field}
/>
)}
<Textarea
placeholder="Enter the text you want to generate..."
className="min-h-[150px]"
{...field}
/>
</FormControl>
<FormDescription>Max 5000 characters</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<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>
{form.watch('engine') === 'chatterbox_turbo'
? 'Max 5000 characters. Type / to insert sound effects.'
: 'Max 5000 characters'}
Natural language instructions to control speech delivery (tone, emotion, pace).
Max 500 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch('engine') === 'qwen' && (
<div className="grid gap-4 md:grid-cols-3">
<FormField
control={form.control}
name="instruct"
name="language"
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>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{LANGUAGE_OPTIONS.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<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'
: form.watch('engine') === 'chatterbox_turbo'
? 'chatterbox_turbo'
: `qwen:${form.watch('modelSize') || '1.7B'}`
}
onValueChange={(value) => {
if (value === 'luxtts') {
form.setValue('engine', 'luxtts');
form.setValue('language', 'en');
} else if (value === 'chatterbox') {
form.setValue('engine', 'chatterbox');
} else if (value === 'chatterbox_turbo') {
form.setValue('engine', 'chatterbox_turbo');
form.setValue('language', 'en');
} 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>
<SelectItem value="chatterbox_turbo">Chatterbox Turbo</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{form.watch('engine') === 'luxtts'
? 'Fast, English-focused'
: form.watch('engine') === 'chatterbox'
? '23 languages, incl. Hebrew'
: form.watch('engine') === 'chatterbox_turbo'
? 'English, [laugh] [cough] tags'
: 'Multi-language, two sizes'}
</FormDescription>
</FormItem>
<FormField
control={form.control}
name="language"
render={({ field }) => {
const engineLangs = getLanguageOptionsForEngine(form.watch('engine') || 'qwen');
return (
<FormItem>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{engineLangs.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
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
@@ -218,7 +170,11 @@ 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,422 +0,0 @@
/**
* ParalinguisticInput — a contentEditable rich text input that renders
* Chatterbox Turbo paralinguistic tags (e.g. [laugh]) as inline badges.
*
* Trigger: typing "/" opens an autocomplete dropdown.
* Paste: pasting text with [tag] patterns auto-converts to badges.
* Output: serializes badges back to plain [tag] text for the API.
*/
import { AnimatePresence, motion } from 'framer-motion';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils/cn';
// ── Tag definitions ─────────────────────────────────────────────────
const PARALINGUISTIC_TAGS = [
{ tag: '[laugh]', label: 'laugh', emoji: '\u{1F602}' },
{ tag: '[chuckle]', label: 'chuckle', emoji: '\u{1F60F}' },
{ tag: '[gasp]', label: 'gasp', emoji: '\u{1F62E}' },
{ tag: '[cough]', label: 'cough', emoji: '\u{1F637}' },
{ tag: '[sigh]', label: 'sigh', emoji: '\u{1F614}' },
{ tag: '[groan]', label: 'groan', emoji: '\u{1F629}' },
{ tag: '[sniff]', label: 'sniff', emoji: '\u{1F443}' },
{ tag: '[shush]', label: 'shush', emoji: '\u{1F92B}' },
{ tag: '[clear throat]', label: 'clear throat', emoji: '\u{1F64A}' },
] as const;
const TAG_REGEX = /\[(laugh|chuckle|gasp|cough|sigh|groan|sniff|shush|clear throat)\]/gi;
// Data attribute used to identify badge spans in the DOM
const BADGE_ATTR = 'data-ptag';
// ── Helpers ─────────────────────────────────────────────────────────
/** Build an inline badge <span> for a tag. */
function makeBadgeHTML(tag: string): string {
const entry = PARALINGUISTIC_TAGS.find((t) => t.tag.toLowerCase() === tag.toLowerCase());
const label = entry?.label ?? tag.replace(/[[\]]/g, '');
const emoji = entry?.emoji ?? '';
// Non-editable inline badge. Zero-width spaces around it let the
// caret sit on either side so the user can type before/after.
return `\u200B<span ${BADGE_ATTR}="${tag}" contenteditable="false" class="ptag-badge">${emoji ? `${emoji}\u00A0` : ''}${label}</span>\u200B`;
}
/** Convert plain text with [tag] patterns into HTML with badge spans. */
function textToHTML(text: string): string {
// Escape HTML entities first
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Replace tag patterns with badge HTML
return escaped.replace(TAG_REGEX, (match) => makeBadgeHTML(match));
}
/** Serialize the contentEditable innerHTML back to plain text with [tag] syntax. */
function htmlToText(container: HTMLElement): string {
let result = '';
for (const node of container.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
// Strip zero-width spaces we added around badges
result += (node.textContent ?? '').replace(/\u200B/g, '');
} else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (el.hasAttribute(BADGE_ATTR)) {
result += el.getAttribute(BADGE_ATTR) ?? '';
} else if (el.tagName === 'BR') {
result += '\n';
} else {
// Recurse for nested elements (e.g. spans from paste)
result += htmlToText(el);
}
}
}
return result;
}
/** Get the text content from the current caret position back to the last
* whitespace or start of container, to detect the "/" trigger. */
function getWordBeforeCaret(_container: HTMLElement): { word: string; range: Range | null } {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return { word: '', range: null };
const range = sel.getRangeAt(0).cloneRange();
range.collapse(true);
// Walk backwards from caret through the text node
const textNode = range.startContainer;
if (textNode.nodeType !== Node.TEXT_NODE) return { word: '', range: null };
const text = textNode.textContent ?? '';
const offset = range.startOffset;
let start = offset;
while (
start > 0 &&
text[start - 1] !== ' ' &&
text[start - 1] !== '\n' &&
text[start - 1] !== '\u00A0'
) {
start--;
}
const word = text.slice(start, offset);
const wordRange = document.createRange();
wordRange.setStart(textNode, start);
wordRange.setEnd(textNode, offset);
return { word, range: wordRange };
}
// ── Component ───────────────────────────────────────────────────────
export interface ParalinguisticInputProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
onClick?: () => void;
onFocus?: () => void;
}
export interface ParalinguisticInputRef {
focus: () => void;
element: HTMLDivElement | null;
}
export const ParalinguisticInput = forwardRef<ParalinguisticInputRef, ParalinguisticInputProps>(
function ParalinguisticInput(
{ value, onChange, placeholder, disabled, className, style, onClick, onFocus },
ref,
) {
const editorRef = useRef<HTMLDivElement>(null);
const [showMenu, setShowMenu] = useState(false);
const [menuFilter, setMenuFilter] = useState('');
const [menuIndex, setMenuIndex] = useState(0);
const [menuPosition, setMenuPosition] = useState<{ bottom: number; left: number }>({
bottom: 0,
left: 0,
});
const triggerRangeRef = useRef<Range | null>(null);
const lastSerializedRef = useRef<string>('');
const isComposingRef = useRef(false);
useImperativeHandle(ref, () => ({
focus: () => editorRef.current?.focus(),
element: editorRef.current,
}));
// Filtered tag list for the autocomplete menu
const filteredTags = PARALINGUISTIC_TAGS.filter((t) =>
t.label.toLowerCase().includes(menuFilter.toLowerCase()),
);
// ── Sync external value → editor ──────────────────────────────
useEffect(() => {
const el = editorRef.current;
if (!el) return;
// Only update DOM if the external value differs from what we last emitted
if (value !== undefined && value !== lastSerializedRef.current) {
lastSerializedRef.current = value;
el.innerHTML = value ? textToHTML(value) : '';
}
}, [value]);
// ── Emit plain-text value on input ────────────────────────────
const emitChange = useCallback(() => {
const el = editorRef.current;
if (!el || !onChange) return;
const text = htmlToText(el);
lastSerializedRef.current = text;
onChange(text);
}, [onChange]);
// ── Insert a tag badge at the caret ───────────────────────────
const insertTag = useCallback(
(tag: string) => {
const el = editorRef.current;
if (!el) return;
// Delete the /filter text
const wordRange = triggerRangeRef.current;
if (wordRange) {
wordRange.deleteContents();
}
// Insert badge HTML
const temp = document.createElement('span');
temp.innerHTML = makeBadgeHTML(tag);
const frag = document.createDocumentFragment();
let lastNode: Node | null = null;
while (temp.firstChild) {
lastNode = frag.appendChild(temp.firstChild);
}
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(frag);
// Move caret after the badge
if (lastNode) {
const newRange = document.createRange();
newRange.setStartAfter(lastNode);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
}
}
setShowMenu(false);
setMenuFilter('');
emitChange();
el.focus();
},
[emitChange],
);
// ── Handle keydown for autocomplete navigation ────────────────
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (showMenu) {
if (filteredTags.length === 0) {
if (e.key === 'Escape') {
e.preventDefault();
setShowMenu(false);
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setMenuIndex((i) => (i + 1) % filteredTags.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setMenuIndex((i) => (i - 1 + filteredTags.length) % filteredTags.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (filteredTags[menuIndex]) {
insertTag(filteredTags[menuIndex].tag);
}
} else if (e.key === 'Escape') {
e.preventDefault();
setShowMenu(false);
}
} else {
// Prevent Enter from creating <div> blocks in contentEditable
if (e.key === 'Enter' && !e.shiftKey) {
// Let the form handle submit
}
}
},
[showMenu, filteredTags, menuIndex, insertTag],
);
// ── Handle input (check for / trigger) ────────────────────────
const handleInput = useCallback(() => {
if (isComposingRef.current) return;
const el = editorRef.current;
if (!el) return;
const { word, range } = getWordBeforeCaret(el);
if (word.startsWith('/')) {
const filter = word.slice(1); // strip the /
setMenuFilter(filter);
setMenuIndex(0);
triggerRangeRef.current = range;
// Position the menu above the caret using viewport coords (portalled)
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const rect = sel.getRangeAt(0).getBoundingClientRect();
setMenuPosition({
bottom: window.innerHeight - rect.top + 4,
left: rect.left,
});
}
setShowMenu(true);
} else {
setShowMenu(false);
}
emitChange();
}, [emitChange]);
// ── Handle paste — convert [tag] patterns to badges ───────────
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
if (!text) return;
const el = editorRef.current;
if (!el) return;
const html = textToHTML(text);
// Insert at caret
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
const temp = document.createElement('div');
temp.innerHTML = html;
const frag = document.createDocumentFragment();
let lastNode: Node | null = null;
while (temp.firstChild) {
lastNode = frag.appendChild(temp.firstChild);
}
range.insertNode(frag);
if (lastNode) {
const newRange = document.createRange();
newRange.setStartAfter(lastNode);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
}
}
emitChange();
},
[emitChange],
);
// ── Show placeholder ──────────────────────────────────────────
const isEmpty = !value || value.trim() === '';
return (
<div className="relative">
{/* Placeholder */}
{isEmpty && placeholder && (
<div
className="pointer-events-none absolute inset-0 text-sm text-muted-foreground/60 px-3 py-2 select-none"
aria-hidden
>
{placeholder}
</div>
)}
{/* Editable area */}
<div
ref={editorRef}
contentEditable={!disabled}
suppressContentEditableWarning
role={disabled ? undefined : 'textbox'}
aria-multiline={disabled ? undefined : true}
aria-placeholder={placeholder}
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
className={cn(
'min-h-[32px] text-sm whitespace-pre-wrap break-words outline-none',
'[&_.ptag-badge]:inline-flex [&_.ptag-badge]:items-center [&_.ptag-badge]:rounded-full',
'[&_.ptag-badge]:bg-accent/20 [&_.ptag-badge]:text-accent [&_.ptag-badge]:border [&_.ptag-badge]:border-accent/30',
'[&_.ptag-badge]:px-2 [&_.ptag-badge]:py-0 [&_.ptag-badge]:text-xs [&_.ptag-badge]:font-medium',
'[&_.ptag-badge]:mx-0.5 [&_.ptag-badge]:select-none [&_.ptag-badge]:cursor-default',
'[&_.ptag-badge]:align-baseline',
disabled && 'opacity-50 cursor-not-allowed',
className,
)}
style={style}
onInput={!disabled ? handleInput : undefined}
onKeyDown={!disabled ? handleKeyDown : undefined}
onPaste={!disabled ? handlePaste : undefined}
onClick={!disabled ? onClick : undefined}
onFocus={!disabled ? onFocus : undefined}
onBlur={() => {
setShowMenu(false);
triggerRangeRef.current = null;
}}
onCompositionStart={() => {
isComposingRef.current = true;
}}
onCompositionEnd={() => {
isComposingRef.current = false;
handleInput();
}}
/>
{/* Autocomplete dropdown — portalled to body, positioned above the caret */}
{showMenu &&
filteredTags.length > 0 &&
createPortal(
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.12 }}
className="fixed z-[9999] min-w-[200px] max-h-[280px] overflow-y-auto rounded-lg border border-border bg-popover shadow-lg"
style={{
bottom: menuPosition.bottom,
left: menuPosition.left,
}}
>
{filteredTags.map((t, i) => (
<button
key={t.tag}
type="button"
className={cn(
'flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left transition-colors',
i === menuIndex
? 'bg-accent/20 text-accent-foreground'
: 'text-popover-foreground hover:bg-muted/50',
)}
onMouseDown={(e) => {
e.preventDefault(); // Keep focus in editor
insertTag(t.tag);
}}
onMouseEnter={() => setMenuIndex(i)}
>
<span className="text-base leading-none">{t.emoji}</span>
<span>{t.label}</span>
<span className="ml-auto text-xs text-muted-foreground font-mono">{t.tag}</span>
</button>
))}
</motion.div>
</AnimatePresence>,
document.body,
)}
</div>
);
},
);
+55 -130
View File
@@ -1,15 +1,13 @@
import { useQueryClient } from '@tanstack/react-query';
import {
AudioWaveform,
Download,
FileArchive,
Loader2,
MoreHorizontal,
Play,
RotateCcw,
Trash2,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import Loader from 'react-loaders';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -38,8 +36,7 @@ import {
useImportGeneration,
} from '@/lib/hooks/useHistory';
import { cn } from '@/lib/utils/cn';
import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format';
import { useGenerationStore } from '@/stores/generationStore';
import { formatDate, formatDuration } from '@/lib/utils/format';
import { usePlayerStore } from '@/stores/playerStore';
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
@@ -57,12 +54,9 @@ export function HistoryTable() {
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 [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(null);
const limit = 20;
const { toast } = useToast();
const queryClient = useQueryClient();
const {
data: historyData,
@@ -77,7 +71,6 @@ export function HistoryTable() {
const exportGeneration = useExportGeneration();
const exportGenerationAudio = useExportGenerationAudio();
const importGeneration = useImportGeneration();
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
const currentAudioId = usePlayerStore((state) => state.audioId);
@@ -201,20 +194,6 @@ export function HistoryTable() {
}
};
const handleRetry = async (generationId: string) => {
try {
const result = await apiClient.retryGeneration(generationId);
addPendingGeneration(result.id);
queryClient.invalidateQueries({ queryKey: ['history'] });
} catch (error) {
toast({
title: 'Retry failed',
description: error instanceof Error ? error.message : 'Could not retry generation',
variant: 'destructive',
});
}
};
const handleImportConfirm = () => {
if (selectedFile) {
importGeneration.mutate(selectedFile, {
@@ -271,54 +250,25 @@ export function HistoryTable() {
>
{history.map((gen) => {
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
const isGenerating = gen.status === 'generating';
const isFailed = gen.status === 'failed';
const isPlayable = !isGenerating && !isFailed;
return (
<div
key={gen.id}
role={isPlayable ? 'button' : undefined}
tabIndex={isPlayable ? 0 : undefined}
className={cn(
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card transition-colors text-left w-full',
isPlayable && 'hover:bg-muted/70 cursor-pointer',
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors text-left w-full',
isCurrentlyPlaying && 'bg-muted/70',
)}
aria-label={
isGenerating
? `Generating speech for ${gen.profile_name}...`
: isFailed
? `Generation failed for ${gen.profile_name}`
: isCurrentlyPlaying
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Press Enter to play.`
}
onMouseDown={(e) => {
if (!isPlayable) return;
// Don't trigger play if clicking on textarea or if text is selected
const target = e.target as HTMLElement;
if (target.closest('textarea') || window.getSelection()?.toString()) {
return;
}
handlePlay(gen.id, gen.text, gen.profile_id);
}}
onKeyDown={(e) => {
if (!isPlayable) return;
const target = e.target as HTMLElement;
if (target.closest('textarea') || target.closest('button')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handlePlay(gen.id, gen.text, gen.profile_id);
}
}}
>
{/* Status icon */}
<div className="flex items-center shrink-0 w-10 justify-center overflow-hidden">
<div className="scale-50">
<Loader
type={isGenerating ? 'line-scale' : 'line-scale-pulse-out-rapid'}
active={isGenerating || isCurrentlyPlaying}
/>
</div>
{/* Waveform icon */}
<div className="flex items-center shrink-0">
<AudioWaveform className="h-5 w-5 text-muted-foreground" />
</div>
{/* Left side - Meta information */}
@@ -329,22 +279,11 @@ export function HistoryTable() {
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{gen.language}</span>
<span className="text-xs text-muted-foreground">
{formatEngineName(gen.engine, gen.model_size)}
{formatDuration(gen.duration)}
</span>
{isFailed ? (
<span className="text-xs text-destructive">Failed</span>
) : !isGenerating ? (
<span className="text-xs text-muted-foreground">
{formatDuration(gen.duration ?? 0)}
</span>
) : null}
</div>
<div className="text-xs text-muted-foreground">
{isGenerating ? (
<span className="text-accent">Generating...</span>
) : (
formatDate(gen.created_at)
)}
{formatDate(gen.created_at)}
</div>
</div>
@@ -354,70 +293,57 @@ export function HistoryTable() {
value={gen.text}
className="flex-1 resize-none text-sm text-muted-foreground select-text"
readOnly
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}`}
/>
</div>
{/* Far right - Actions */}
{/* Far right - Ellipsis actions */}
<div
className="w-10 shrink-0 flex justify-end items-center"
className="w-10 shrink-0 flex justify-end"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{isFailed ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Retry generation"
onClick={() => handleRetry(gen.id)}
>
<RotateCcw className="h-4 w-4" />
</Button>
) : isPlayable ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
>
<Play className="mr-2 h-4 w-4" />
Play
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDownloadAudio(gen.id, gen.text)}
disabled={exportGenerationAudio.isPending}
>
<Download className="mr-2 h-4 w-4" />
Export Audio
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExportPackage(gen.id, gen.text)}
disabled={exportGeneration.isPending}
>
<FileArchive className="mr-2 h-4 w-4" />
Export Package
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
disabled={deleteGeneration.isPending}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
>
<Play className="mr-2 h-4 w-4" />
Play
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDownloadAudio(gen.id, gen.text)}
disabled={exportGenerationAudio.isPending}
>
<Download className="mr-2 h-4 w-4" />
Export Audio
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExportPackage(gen.id, gen.text)}
disabled={exportGeneration.isPending}
>
<FileArchive className="mr-2 h-4 w-4" />
Export Package
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
disabled={deleteGeneration.isPending}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
@@ -445,8 +371,7 @@ export function HistoryTable() {
<DialogHeader>
<DialogTitle>Delete Generation</DialogTitle>
<DialogDescription>
Are you sure you want to delete this generation from "{generationToDelete?.name}"?
This action cannot be undone.
Are you sure you want to delete this generation from "{generationToDelete?.name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
+7 -7
View File
@@ -13,7 +13,7 @@ import {
} from '@/components/ui/dialog';
import { useToast } from '@/components/ui/use-toast';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useImportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
@@ -77,9 +77,9 @@ export function MainEditor() {
return (
// Main view: Profiles top left, Generator bottom left, History right
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative">
{/* Left Column */}
<div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
<div className="flex flex-col min-h-0 overflow-hidden relative">
{/* Scroll Mask - Always visible, behind content */}
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
@@ -110,7 +110,10 @@ export function MainEditor() {
{/* Scrollable Content */}
<div
ref={scrollRef}
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
className={cn(
'flex-1 min-h-0 overflow-y-auto pt-14',
isPlayerVisible ? BOTTOM_SAFE_AREA_PADDING : 'pb-4',
)}
>
<div className="flex flex-col gap-6">
<div className="shrink-0 flex flex-col">
@@ -120,9 +123,6 @@ export function MainEditor() {
</div>
</div>
{/* Divider - single column only */}
{/* <div className="border-t border-border -my-3 lg:hidden" /> */}
{/* Right Column - History */}
<div className="flex flex-col min-h-0 overflow-hidden">
<HistoryTable />
+1 -1
View File
@@ -2,7 +2,7 @@ import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
export function ModelsTab() {
return (
<div className="h-full flex flex-col">
<div className="space-y-4 overflow-y-auto flex flex-col">
<ModelManagement />
</div>
);
@@ -1,12 +1,9 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, XCircle } from 'lucide-react';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Form,
FormControl,
@@ -17,10 +14,10 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { useToast } from '@/components/ui/use-toast';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { usePlatform } from '@/platform/PlatformContext';
const connectionSchema = z.object({
serverUrl: z.string().url('Please enter a valid URL'),
@@ -34,10 +31,7 @@ export function ConnectionForm() {
const setServerUrl = useServerStore((state) => state.setServerUrl);
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
const mode = useServerStore((state) => state.mode);
const setMode = useServerStore((state) => state.setMode);
const { toast } = useToast();
const { data: health, isLoading, error: healthError } = useServerHealth();
const form = useForm<ConnectionFormValues>({
resolver: zodResolver(connectionSchema),
@@ -55,7 +49,7 @@ export function ConnectionForm() {
function onSubmit(data: ConnectionFormValues) {
setServerUrl(data.serverUrl);
form.reset(data);
form.reset(data); // Reset form state after successful submission
toast({
title: 'Server URL updated',
description: `Connected to ${data.serverUrl}`,
@@ -63,7 +57,7 @@ export function ConnectionForm() {
}
return (
<Card role="region" aria-label="Server Connection" tabIndex={0}>
<Card>
<CardHeader>
<CardTitle>Server Connection</CardTitle>
</CardHeader>
@@ -89,42 +83,10 @@ export function ConnectionForm() {
</form>
</Form>
{/* Connection status */}
<div className="mt-4">
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">Checking connection...</span>
</div>
) : healthError ? (
<div className="flex items-center gap-2">
<XCircle className="h-4 w-4 text-destructive" />
<span className="text-sm text-destructive">
Connection failed: {healthError.message}
</span>
</div>
) : health ? (
<div className="flex flex-wrap gap-2">
<Badge
variant={health.model_loaded || health.model_downloaded ? 'default' : 'secondary'}
>
{health.model_loaded || health.model_downloaded ? 'Model Ready' : 'No Model'}
</Badge>
<Badge variant={health.gpu_available ? 'default' : 'secondary'}>
GPU: {health.gpu_available ? 'Available' : 'Not Available'}
</Badge>
{health.vram_used_mb && (
<Badge variant="outline">VRAM: {health.vram_used_mb.toFixed(0)} MB</Badge>
)}
</div>
) : null}
</div>
<div className="mt-6 pt-6 border-t">
<div className="flex items-start space-x-3">
<Checkbox
id="keepServerRunning"
className="mt-[6px]"
checked={keepServerRunningOnClose}
onCheckedChange={(checked: boolean) => {
setKeepServerRunningOnClose(checked);
@@ -153,39 +115,6 @@ export function ConnectionForm() {
</div>
</div>
</div>
{platform.metadata.isTauri && (
<div className="mt-6 pt-6 border-t">
<div className="flex items-start space-x-3">
<Checkbox
id="allowNetworkAccess"
className="mt-[6px]"
checked={mode === 'remote'}
onCheckedChange={(checked: boolean) => {
setMode(checked ? 'remote' : 'local');
toast({
title: 'Setting updated',
description: checked
? 'Network access enabled. Restart the app to apply.'
: 'Network access disabled. Restart the app to apply.',
});
}}
/>
<div className="space-y-1">
<label
htmlFor="allowNetworkAccess"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Allow network access
</label>
<p className="text-sm text-muted-foreground">
Makes the server accessible from other devices on your network. Restart the app
after changing this setting.
</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
@@ -1,116 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Slider } from '@/components/ui/slider';
import { useServerStore } from '@/stores/serverStore';
export function GenerationSettings() {
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
return (
<Card role="region" aria-label="Generation Settings" tabIndex={0}>
<CardHeader>
<CardTitle>Generation Settings</CardTitle>
<CardDescription>
Controls for long text generation. These settings apply to all engines.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<label htmlFor="maxChunkChars" className="text-sm font-medium leading-none">
Auto-chunking limit
</label>
<span className="text-sm tabular-nums text-muted-foreground">
{maxChunkChars} chars
</span>
</div>
<Slider
id="maxChunkChars"
value={[maxChunkChars]}
onValueChange={([value]) => setMaxChunkChars(value)}
min={100}
max={5000}
step={50}
aria-label="Auto-chunking character limit"
/>
<p className="text-sm text-muted-foreground">
Long text is split into chunks at sentence boundaries before generating. Lower values
can improve quality for long outputs.
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label htmlFor="crossfadeMs" className="text-sm font-medium leading-none">
Chunk crossfade
</label>
<span className="text-sm tabular-nums text-muted-foreground">
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
</span>
</div>
<Slider
id="crossfadeMs"
value={[crossfadeMs]}
onValueChange={([value]) => setCrossfadeMs(value)}
min={0}
max={200}
step={10}
aria-label="Chunk crossfade duration"
/>
<p className="text-sm text-muted-foreground">
Blends audio between chunks to smooth transitions. Set to 0 for a hard cut.
</p>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="normalizeAudio"
checked={normalizeAudio}
onCheckedChange={setNormalizeAudio}
className="mt-[6px]"
/>
<div className="space-y-1">
<label
htmlFor="normalizeAudio"
className="text-sm font-medium leading-none cursor-pointer"
>
Normalize audio
</label>
<p className="text-sm text-muted-foreground">
Adjusts output volume to a consistent level across generations.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="autoplayOnGenerate"
checked={autoplayOnGenerate}
onCheckedChange={setAutoplayOnGenerate}
className="mt-[6px]"
/>
<div className="space-y-1">
<label
htmlFor="autoplayOnGenerate"
className="text-sm font-medium leading-none cursor-pointer"
>
Autoplay on generate
</label>
<p className="text-sm text-muted-foreground">
Automatically play audio when a generation completes.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -1,366 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, Download, Loader2, RotateCw, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
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>GPU Acceleration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Current status */}
<div className="space-y-1">
<div className="text-sm font-medium">Backend</div>
<div className="text-sm text-muted-foreground">
{isCurrentlyCuda
? 'CUDA (GPU accelerated)'
: hasNativeGpu
? `${health.backend_type === 'mlx' ? 'MLX' : 'PyTorch'} (GPU accelerated)`
: 'CPU'}
</div>
</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 */}
{/* 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>
);
}
File diff suppressed because it is too large Load Diff
@@ -3,13 +3,14 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useServerHealth } from '@/lib/hooks/useServer';
import { useServerStore } from '@/stores/serverStore';
import { ModelProgress } from './ModelProgress';
export function ServerStatus() {
const { data: health, isLoading, error } = useServerHealth();
const serverUrl = useServerStore((state) => state.serverUrl);
return (
<Card role="region" aria-label="Server Status" tabIndex={0}>
<Card>
<CardHeader>
<CardTitle>Server Status</CardTitle>
</CardHeader>
@@ -19,6 +20,16 @@ export function ServerStatus() {
<div className="font-mono text-sm">{serverUrl}</div>
</div>
{/* Model download progress */}
<div className="space-y-2">
<ModelProgress modelName="qwen-tts-1.7B" displayName="Qwen TTS 1.7B" />
<ModelProgress modelName="qwen-tts-0.6B" displayName="Qwen TTS 0.6B" />
<ModelProgress modelName="whisper-base" displayName="Whisper Base" />
<ModelProgress modelName="whisper-small" displayName="Whisper Small" />
<ModelProgress modelName="whisper-medium" displayName="Whisper Medium" />
<ModelProgress modelName="whisper-large" displayName="Whisper Large" />
</div>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -20,11 +20,7 @@ export function UpdateStatus() {
}, [platform]);
return (
<Card
role="region"
aria-label="App Updates"
tabIndex={0}
>
<Card>
<CardHeader>
<CardTitle>App Updates</CardTitle>
</CardHeader>
+4 -12
View File
@@ -1,25 +1,17 @@
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
import { GenerationSettings } from '@/components/ServerSettings/GenerationSettings';
import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration';
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
export function ServerTab() {
const platform = usePlatform();
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
return (
<div
className={cn('overflow-y-auto flex flex-col', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}
>
<div className="space-y-4 overflow-y-auto flex flex-col">
<div className="grid gap-4 md:grid-cols-2">
<ConnectionForm />
<GenerationSettings />
{platform.metadata.isTauri && <GpuAcceleration />}
{platform.metadata.isTauri && <UpdateStatus />}
<ServerStatus />
</div>
{platform.metadata.isTauri && <UpdateStatus />}
<div className="py-8 text-center text-sm text-muted-foreground">
Created by{' '}
<a
+22 -11
View File
@@ -1,9 +1,9 @@
import { Link, useMatchRoute } from '@tanstack/react-router';
import { BookOpen, Box, Mic, Server, Speaker, Volume2 } from 'lucide-react';
import { Box, BookOpen, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
import { version } from '../../package.json';
interface SidebarProps {
isMacOS?: boolean;
@@ -19,8 +19,10 @@ const tabs = [
];
export function Sidebar({ isMacOS }: SidebarProps) {
const isGenerating = useGenerationStore((state) => state.isGenerating);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const matchRoute = useMatchRoute();
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
return (
<div
@@ -40,7 +42,9 @@ export function Sidebar({ isMacOS }: SidebarProps) {
const Icon = tab.icon;
// For index route, use exact match; for others, use default matching
const isActive =
tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path });
tab.path === '/'
? matchRoute({ to: '/', exact: true })
: matchRoute({ to: tab.path });
return (
<Link
@@ -60,13 +64,20 @@ export function Sidebar({ isMacOS }: SidebarProps) {
})}
</div>
{/* Version */}
<div
className="mt-auto text-[10px] text-muted-foreground/50 transition-all duration-300"
style={{ paddingBottom: isPlayerOpen ? '7rem' : undefined }}
>
v{version}
</div>
{/* Spacer to push loader to bottom */}
<div className="flex-1" />
{/* Generation Loader */}
{isGenerating && (
<div
className={cn(
'w-full flex items-center justify-center transition-all duration-200',
isPlayerVisible ? 'mb-[120px]' : 'mb-0',
)}
>
<Loader2 className="h-6 w-6 text-accent animate-spin" />
</div>
)}
</div>
);
}
+1 -4
View File
@@ -1,11 +1,8 @@
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 */}
@@ -21,7 +18,7 @@ export function StoriesTab() {
</div>
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
<FloatingGenerateBox showVoiceSelector isPlayerOpen={!!audioUrl} />
<FloatingGenerateBox showVoiceSelector />
</div>
</div>
);
+6 -33
View File
@@ -13,11 +13,8 @@ import {
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { Link } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Download, Plus } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import Loader from 'react-loaders';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
@@ -31,7 +28,6 @@ import {
useStory,
} from '@/lib/hooks/useStories';
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore';
import { SortableStoryChatItem } from './StoryChatItem';
@@ -44,7 +40,6 @@ export function StoryContent() {
const addStoryItem = useAddStoryItem();
const { toast } = useToast();
const scrollRef = useRef<HTMLDivElement>(null);
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
// Add generation popover state
const [searchQuery, setSearchQuery] = useState('');
@@ -58,9 +53,9 @@ export function StoryContent() {
const query = searchQuery.toLowerCase();
return historyData.items.filter(
(gen) =>
gen.status === 'completed' &&
!storyGenerationIds.has(gen.id) &&
(gen.text.toLowerCase().includes(query) || gen.profile_name.toLowerCase().includes(query)),
(gen.text.toLowerCase().includes(query) ||
gen.profile_name.toLowerCase().includes(query)),
);
}, [historyData, story, searchQuery]);
@@ -272,31 +267,7 @@ export function StoryContent() {
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
)}
</div>
<div className="flex gap-2 items-center">
<AnimatePresence>
{pendingCount > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9, width: 0 }}
animate={{ opacity: 1, scale: 1, width: 'auto' }}
exit={{ opacity: 0, scale: 0.9, width: 0 }}
transition={{ duration: 0.2 }}
>
<Link
to="/"
className="flex items-center gap-2 h-8 pl-1.5 pr-3 rounded-full bg-card border border-border hover:bg-muted/50 transition-all duration-200 cursor-pointer"
>
<div className="shrink-0 w-10 h-5 overflow-hidden flex items-center justify-center">
<div className="scale-[0.45]">
<Loader type="line-scale" active />
</div>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
</span>
</Link>
</motion.div>
)}
</AnimatePresence>
<div className="flex gap-2">
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
@@ -316,7 +287,9 @@ export function StoryContent() {
<div className="max-h-60 overflow-y-auto">
{availableGenerations.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{searchQuery ? 'No matching generations found' : 'No available generations'}
{searchQuery
? 'No matching generations found'
: 'No available generations'}
</div>
) : (
availableGenerations.map((gen) => (
+7 -20
View File
@@ -194,29 +194,17 @@ export function StoryList() {
storyList.map((story) => (
<div
key={story.id}
role="button"
tabIndex={0}
className={cn(
'h-24 p-4 border rounded-2xl transition-colors group flex items-center cursor-pointer',
'h-24 p-4 border rounded-2xl transition-colors group flex items-center',
selectedStoryId === story.id && 'bg-muted border-primary',
)}
aria-label={
selectedStoryId === story.id
? `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Selected. Press Enter to select.`
: `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Press Enter to select.`
}
aria-pressed={selectedStoryId === story.id}
onClick={() => setSelectedStoryId(story.id)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedStoryId(story.id);
}
}}
>
<div className="flex items-start justify-between gap-2 w-full min-w-0">
<div className="flex-1 min-w-0 text-left overflow-hidden">
<button
type="button"
className="flex-1 min-w-0 text-left cursor-pointer overflow-hidden"
onClick={() => setSelectedStoryId(story.id)}
>
<h3 className="font-medium truncate">{story.name}</h3>
{story.description && (
<p className="text-sm text-muted-foreground mt-1 truncate">
@@ -230,7 +218,7 @@ export function StoryList() {
<span>•</span>
<span>{formatDate(story.updated_at)}</span>
</div>
</div>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -238,7 +226,6 @@ export function StoryList() {
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for ${story.name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
@@ -736,7 +736,6 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7"
onClick={handlePlayPause}
title="Play/Pause (Space)"
aria-label={isCurrentlyPlaying ? 'Pause' : 'Play'}
>
{isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
@@ -746,7 +745,6 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7"
onClick={handleStop}
disabled={!isCurrentlyPlaying}
aria-label="Stop"
>
<Square className="h-3 w-3" />
</Button>
@@ -764,7 +762,6 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7"
onClick={handleSplit}
title="Split at playhead (S)"
aria-label="Split at playhead"
>
<Scissors className="h-4 w-4" />
</Button>
@@ -774,7 +771,6 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7"
onClick={handleDuplicate}
title="Duplicate (Cmd/Ctrl+D)"
aria-label="Duplicate clip"
>
<Copy className="h-4 w-4" />
</Button>
@@ -784,7 +780,6 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7"
onClick={handleDelete}
title="Delete (Delete/Backspace)"
aria-label="Delete clip"
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -794,22 +789,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
{/* Zoom controls - right side */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Zoom:</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleZoomOut}
aria-label="Zoom out"
>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
<Minus className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleZoomIn}
aria-label="Zoom in"
>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
<Plus className="h-3 w-3" />
</Button>
</div>
@@ -58,7 +58,6 @@ 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;
@@ -140,13 +139,7 @@ export function AudioSampleRecording({
</div>
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
<div className="flex gap-2">
<Button
type="button"
size="icon"
variant="outline"
onClick={onPlayPause}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button
@@ -77,13 +77,7 @@ export function AudioSampleSystem({
</div>
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
<div className="flex gap-2">
<Button
type="button"
size="icon"
variant="outline"
onClick={onPlayPause}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button
@@ -110,7 +110,6 @@ export function AudioSampleUpload({
variant="outline"
onClick={onPlayPause}
disabled={isValidating}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
@@ -61,32 +61,14 @@ export function ProfileCard({ profile }: ProfileCardProps) {
exportProfile.mutate(profile.id);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.closest('button')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect();
}
};
const selectLabel = isSelected
? `${profile.name}, ${profile.language}. Selected as voice for generation.`
: `${profile.name}, ${profile.language}. Select as voice for generation.`;
return (
<>
<Card
className={cn(
'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]',
'cursor-pointer hover:shadow-md transition-all flex flex-col',
isSelected && 'ring-2 ring-primary shadow-md',
)}
onClick={handleSelect}
tabIndex={0}
role="button"
aria-label={selectLabel}
aria-pressed={isSelected}
onKeyDown={handleKeyDown}
>
<CardHeader className="p-3 pb-2">
<CardTitle className="flex items-center gap-1.5 text-base font-medium">
@@ -43,7 +43,7 @@ import {
} from '@/lib/hooks/useProfiles';
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
import { useTranscription } from '@/lib/hooks/useTranscription';
import { convertToWav, formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
@@ -505,23 +505,10 @@ 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: fileToUpload,
file: sampleFile,
referenceText: referenceText,
});
@@ -41,11 +41,9 @@ export function ProfileList() {
</CardContent>
</Card>
) : (
<div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]">
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1 pb-[150px]">
{allProfiles.map((profile) => (
<div key={profile.id} className="shrink-0 w-[200px] lg:w-auto lg:shrink">
<ProfileCard profile={profile} />
</div>
<ProfileCard key={profile.id} profile={profile} />
))}
</div>
)}
@@ -102,7 +102,6 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
className="h-7 w-7 shrink-0"
onClick={handlePlayPause}
disabled={isLoading}
aria-label={isPlaying ? 'Pause sample' : 'Play sample'}
>
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
</Button>
@@ -114,8 +113,6 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
max={100}
step={0.1}
className="flex-1"
aria-label="Sample playback position"
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
/>
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
@@ -131,7 +128,6 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
className="h-7 w-7 shrink-0"
onClick={handleStop}
title="Stop"
aria-label="Stop playback"
>
<X className="h-3.5 w-3.5" />
</Button>
+10 -21
View File
@@ -79,8 +79,8 @@ export function VoicesTab() {
setDialogOpen(true);
};
const handleProfileDelete = async (profileId: string) => {
if (await confirm('Are you sure you want to delete this profile?')) {
const handleDelete = (profileId: string) => {
if (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={() => handleProfileDelete(profile.id)}
onDelete={() => handleDelete(profile.id)}
/>
))}
</TableBody>
@@ -179,36 +179,25 @@ function VoiceRow({
onDelete,
}: VoiceRowProps) {
const { data: samples } = useProfileSamples(profile.id);
const sampleCount = samples?.length || 0;
const rowLabel = `${profile.name}, ${profile.language}, ${generationCount} generations, ${sampleCount} samples. Press Enter to edit.`;
return (
<TableRow className="cursor-pointer" onClick={onEdit}>
<TableCell>
<button
type="button"
className="flex w-full min-w-0 items-center gap-2 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
aria-label={rowLabel}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Mic className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<div className="font-medium truncate">{profile.name}</div>
<div>
<div className="font-medium">{profile.name}</div>
{profile.description && (
<div className="text-sm text-muted-foreground truncate">{profile.description}</div>
<div className="text-sm text-muted-foreground">{profile.description}</div>
)}
</div>
</button>
</div>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>{profile.language}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>{generationCount}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>{sampleCount}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>{samples?.length || 0}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<MultiSelect
options={channels.map((ch) => ({
@@ -224,7 +213,7 @@ function VoiceRow({
<TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={`Actions for ${profile.name}`}>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
+1 -1
View File
@@ -1,5 +1,5 @@
import { Check } from 'lucide-react';
import * as React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
export interface CheckboxProps {
-16
View File
@@ -1,5 +1,4 @@
@import "tailwindcss" source(".");
@import "loaders.css/loaders.min.css";
@theme {
--radius-sm: calc(var(--radius) - 4px);
@@ -156,18 +155,3 @@
animation: fadeIn 0.5s ease-out 0.15s forwards;
opacity: 0;
}
/* react-loaders */
.line-scale-pulse-out-rapid > div,
.line-scale > div {
background-color: hsl(var(--accent)) !important;
}
.loader-hidden {
display: block;
}
.loader-hidden > div > div {
animation-play-state: paused !important;
background-color: hsl(var(--muted-foreground)) !important;
}
+39 -114
View File
@@ -1,30 +1,29 @@
import type { LanguageCode } from '@/lib/constants/languages';
import { useServerStore } from '@/stores/serverStore';
import type { LanguageCode } from '@/lib/constants/languages';
import type {
ActiveTasksResponse,
CudaStatus,
GenerationRequest,
GenerationResponse,
HealthResponse,
HistoryListResponse,
HistoryQuery,
HistoryResponse,
ModelDownloadRequest,
ModelStatusListResponse,
ProfileSampleResponse,
StoryCreate,
StoryDetailResponse,
StoryItemBatchUpdate,
StoryItemCreate,
StoryItemDetail,
StoryItemMove,
StoryItemReorder,
StoryItemSplit,
StoryItemTrim,
StoryResponse,
TranscriptionResponse,
VoiceProfileCreate,
VoiceProfileResponse,
ProfileSampleResponse,
GenerationRequest,
GenerationResponse,
HistoryQuery,
HistoryListResponse,
HistoryResponse,
TranscriptionResponse,
HealthResponse,
ModelStatusListResponse,
ModelDownloadRequest,
ActiveTasksResponse,
StoryCreate,
StoryResponse,
StoryDetailResponse,
StoryItemCreate,
StoryItemDetail,
StoryItemBatchUpdate,
StoryItemReorder,
StoryItemMove,
StoryItemTrim,
StoryItemSplit,
} from './types';
class ApiClient {
@@ -200,12 +199,6 @@ class ApiClient {
});
}
async retryGeneration(generationId: string): Promise<GenerationResponse> {
return this.request<GenerationResponse>(`/generate/${generationId}/retry`, {
method: 'POST',
});
}
// History
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
const params = new URLSearchParams();
@@ -258,13 +251,7 @@ 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);
@@ -284,11 +271,6 @@ class ApiClient {
return response.json();
}
// Generation status SSE
getGenerationStatusUrl(generationId: string): string {
return `${this.getBaseUrl()}/generate/${generationId}/status`;
}
// Audio
getAudioUrl(audioId: string): string {
return `${this.getBaseUrl()}/audio/${audioId}`;
@@ -327,28 +309,8 @@ class ApiClient {
return this.request<ModelStatusListResponse>('/models/status');
}
async getModelsCacheDir(): Promise<{ path: string }> {
return this.request<{ path: string }>('/models/cache-dir');
}
async migrateModels(destination: string): Promise<{ source: string; destination: string }> {
return this.request('/models/migrate', {
method: 'POST',
body: JSON.stringify({ destination }),
});
}
getMigrationProgressUrl(): string {
return `${this.getBaseUrl()}/models/migrate/progress`;
}
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
console.log(
'[API] triggerModelDownload called for:',
modelName,
'at',
new Date().toISOString(),
);
console.log('[API] triggerModelDownload called for:', modelName, 'at', new Date().toISOString());
const result = await this.request<{ message: string }>('/models/download', {
method: 'POST',
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
@@ -363,28 +325,11 @@ class ApiClient {
});
}
async unloadModel(modelName: string): Promise<{ message: string }> {
return this.request<{ message: string }>(`/models/${modelName}/unload`, {
method: 'POST',
});
}
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<{
@@ -398,7 +343,10 @@ 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;
@@ -440,7 +388,10 @@ 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 }),
@@ -451,30 +402,16 @@ 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');
@@ -531,33 +468,21 @@ 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),
+3 -57
View File
@@ -34,11 +34,6 @@ export interface GenerationRequest {
language: LanguageCode;
seed?: number;
model_size?: '1.7B' | '0.6B';
engine?: 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo';
instruct?: string;
max_chunk_chars?: number;
crossfade_ms?: number;
normalize?: boolean;
}
export interface GenerationResponse {
@@ -46,14 +41,9 @@ export interface GenerationResponse {
profile_id: string;
text: string;
language: string;
audio_path?: string;
duration?: number;
audio_path: string;
duration: number;
seed?: number;
instruct?: string;
engine?: string;
model_size?: string;
status: 'generating' | 'completed' | 'failed';
error?: string;
created_at: string;
}
@@ -88,29 +78,7 @@ 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 {
@@ -127,29 +95,12 @@ 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
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[];
}
@@ -162,11 +113,6 @@ 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 {
+12 -72
View File
@@ -1,86 +1,26 @@
/**
* Supported languages for voice generation, per engine.
*
* Qwen3-TTS supports 10 languages.
* LuxTTS is English-only.
* Chatterbox Multilingual supports 23 languages.
* Chatterbox Turbo is English-only.
* Supported languages for Qwen3-TTS
* Based on: https://github.com/QwenLM/Qwen3-TTS
*/
/** All languages that any engine supports. */
export const ALL_LANGUAGES = {
ar: 'Arabic',
da: 'Danish',
de: 'German',
el: 'Greek',
export const SUPPORTED_LANGUAGES = {
zh: 'Chinese',
en: 'English',
es: 'Spanish',
fi: 'Finnish',
fr: 'French',
he: 'Hebrew',
hi: 'Hindi',
it: 'Italian',
ja: 'Japanese',
ko: 'Korean',
ms: 'Malay',
nl: 'Dutch',
no: 'Norwegian',
pl: 'Polish',
pt: 'Portuguese',
de: 'German',
fr: 'French',
ru: 'Russian',
sv: 'Swedish',
sw: 'Swahili',
tr: 'Turkish',
zh: 'Chinese',
pt: 'Portuguese',
es: 'Spanish',
it: 'Italian',
} as const;
export type LanguageCode = keyof typeof ALL_LANGUAGES;
export type LanguageCode = keyof typeof SUPPORTED_LANGUAGES;
/** Per-engine supported language codes. */
export const ENGINE_LANGUAGES: Record<string, readonly LanguageCode[]> = {
qwen: ['zh', 'en', 'ja', 'ko', 'de', 'fr', 'ru', 'pt', 'es', 'it'],
luxtts: ['en'],
chatterbox: [
'ar',
'da',
'de',
'el',
'en',
'es',
'fi',
'fr',
'he',
'hi',
'it',
'ja',
'ko',
'ms',
'nl',
'no',
'pl',
'pt',
'ru',
'sv',
'sw',
'tr',
'zh',
],
chatterbox_turbo: ['en'],
} as const;
export const LANGUAGE_CODES = Object.keys(SUPPORTED_LANGUAGES) as LanguageCode[];
/** Helper: get language options for a given engine. */
export function getLanguageOptionsForEngine(engine: string) {
const codes = ENGINE_LANGUAGES[engine] ?? ENGINE_LANGUAGES.qwen;
return codes.map((code) => ({
value: code,
label: ALL_LANGUAGES[code],
}));
}
// ── Backwards-compatible exports used elsewhere ──────────────────────
export const SUPPORTED_LANGUAGES = ALL_LANGUAGES;
export const LANGUAGE_CODES = Object.keys(ALL_LANGUAGES) as LanguageCode[];
export const LANGUAGE_OPTIONS = LANGUAGE_CODES.map((code) => ({
value: code,
label: ALL_LANGUAGES[code],
label: SUPPORTED_LANGUAGES[code],
}));
+15 -21
View File
@@ -20,13 +20,11 @@ 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
@@ -89,34 +87,31 @@ 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' });
// 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);
// 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
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
};
mediaRecorder.onerror = (event) => {
@@ -172,10 +167,9 @@ 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);
}
+19 -47
View File
@@ -8,15 +8,14 @@ import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
import { useGeneration } from '@/lib/hooks/useGeneration';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { useGenerationStore } from '@/stores/generationStore';
import { useServerStore } from '@/stores/serverStore';
import { usePlayerStore } from '@/stores/playerStore';
const generationSchema = z.object({
text: z.string().min(1, 'Text is required').max(50000),
text: z.string().min(1, 'Text is required').max(5000),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
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', 'chatterbox_turbo']).optional(),
});
export type GenerationFormValues = z.infer<typeof generationSchema>;
@@ -29,10 +28,8 @@ interface UseGenerationFormOptions {
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const { toast } = useToast();
const generation = useGeneration();
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
@@ -50,7 +47,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
seed: undefined,
modelSize: '1.7B',
instruct: '',
engine: 'qwen',
...options.defaultValues,
},
});
@@ -69,27 +65,11 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
}
try {
const engine = data.engine || 'qwen';
const modelName =
engine === 'luxtts'
? 'luxtts'
: engine === 'chatterbox'
? 'chatterbox-tts'
: engine === 'chatterbox_turbo'
? 'chatterbox-turbo'
: `qwen-tts-${data.modelSize}`;
const displayName =
engine === 'luxtts'
? 'LuxTTS'
: engine === 'chatterbox'
? 'Chatterbox TTS'
: engine === 'chatterbox_turbo'
? 'Chatterbox Turbo'
: data.modelSize === '1.7B'
? 'Qwen TTS 1.7B'
: 'Qwen TTS 0.6B';
setIsGenerating(true);
const modelName = `qwen-tts-${data.modelSize}`;
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
// Check if model needs downloading
try {
const modelStatus = await apiClient.getModelStatus();
const model = modelStatus.models.find((m) => m.model_name === modelName);
@@ -102,33 +82,24 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
console.error('Failed to check model status:', error);
}
const isQwen = engine === 'qwen';
// This now returns immediately with status="generating"
const result = await generation.mutateAsync({
profile_id: selectedProfileId,
text: data.text,
language: data.language,
seed: data.seed,
model_size: isQwen ? data.modelSize : undefined,
engine,
instruct: isQwen ? data.instruct || undefined : undefined,
max_chunk_chars: maxChunkChars,
crossfade_ms: crossfadeMs,
normalize: normalizeAudio,
model_size: data.modelSize,
instruct: data.instruct || undefined,
});
// Track this generation for SSE status updates
addPendingGeneration(result.id);
// Reset form immediately — user can start typing again
form.reset({
text: '',
language: data.language,
seed: undefined,
modelSize: data.modelSize,
instruct: '',
engine: data.engine,
toast({
title: 'Generation complete!',
description: `Audio generated (${result.duration.toFixed(2)}s)`,
});
const audioUrl = apiClient.getAudioUrl(result.id);
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
form.reset();
options.onSuccess?.(result.id);
} catch (error) {
toast({
@@ -137,6 +108,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
variant: 'destructive',
});
} finally {
setIsGenerating(false);
setDownloadingModelName(null);
setDownloadingDisplayName(null);
}
-154
View File
@@ -1,154 +0,0 @@
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
interface GenerationStatusEvent {
id: string;
status: 'generating' | 'completed' | 'failed' | 'not_found';
duration?: number;
error?: string;
}
/**
* Subscribes to SSE for all pending generations. When a generation completes,
* invalidates the history query, removes it from pending, and auto-plays
* if the player is idle.
*/
export function useGenerationProgress() {
const queryClient = useQueryClient();
const { toast } = useToast();
const pendingIds = useGenerationStore((s) => s.pendingGenerationIds);
const removePendingGeneration = useGenerationStore((s) => s.removePendingGeneration);
const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd);
const isPlaying = usePlayerStore((s) => s.isPlaying);
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
// Keep refs to avoid stale closures in EventSource handlers
const isPlayingRef = useRef(isPlaying);
const autoplayRef = useRef(autoplayOnGenerate);
isPlayingRef.current = isPlaying;
autoplayRef.current = autoplayOnGenerate;
// Track active EventSource instances
const eventSourcesRef = useRef<Map<string, EventSource>>(new Map());
// Unmount-only cleanup — close all SSE connections when the hook is torn down
useEffect(() => {
const sources = eventSourcesRef.current;
return () => {
for (const source of sources.values()) {
source.close();
}
sources.clear();
};
}, []);
useEffect(() => {
const currentSources = eventSourcesRef.current;
// Close SSE connections for IDs no longer pending
for (const [id, source] of currentSources.entries()) {
if (!pendingIds.has(id)) {
source.close();
currentSources.delete(id);
}
}
// Open SSE connections for new pending IDs
for (const id of pendingIds) {
if (currentSources.has(id)) continue;
const url = apiClient.getGenerationStatusUrl(id);
const source = new EventSource(url);
source.onmessage = (event) => {
try {
const data: GenerationStatusEvent = JSON.parse(event.data);
if (data.status === 'completed') {
source.close();
currentSources.delete(id);
removePendingGeneration(id);
// Refresh history to pick up the completed generation
queryClient.invalidateQueries({ queryKey: ['history'] });
// If this generation was queued for a story, add it now
const storyId = removePendingStoryAdd(id);
if (storyId) {
apiClient
.addStoryItem(storyId, { generation_id: id })
.then(() => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', storyId] });
toast({
title: 'Added to story',
description: data.duration
? `Audio generated (${data.duration.toFixed(2)}s) and added to story`
: 'Audio generated and added to story',
});
})
.catch(() => {
toast({
title: 'Generation complete',
description: 'Audio generated but failed to add to story',
variant: 'destructive',
});
});
} else {
// toast({
// title: 'Generation complete!',
// description: data.duration
// ? `Audio generated (${data.duration.toFixed(2)}s)`
// : 'Audio generated',
// });
}
// Auto-play if enabled and nothing is currently playing
if (autoplayRef.current && !isPlayingRef.current) {
const genAudioUrl = apiClient.getAudioUrl(id);
setAudioWithAutoPlay(genAudioUrl, id, '', '');
}
} else if (data.status === 'failed' || data.status === 'not_found') {
source.close();
currentSources.delete(id);
removePendingGeneration(id);
removePendingStoryAdd(id);
queryClient.invalidateQueries({ queryKey: ['history'] });
toast({
title: data.status === 'not_found' ? 'Generation not found' : 'Generation failed',
description: data.error || 'An error occurred during generation',
variant: 'destructive',
});
}
} catch {
// Ignore parse errors from heartbeats etc
}
};
source.onerror = () => {
// EventSource auto-reconnects, but if we get repeated errors
// just clean up
source.close();
currentSources.delete(id);
removePendingGeneration(id);
};
currentSources.set(id, source);
}
}, [
pendingIds,
removePendingGeneration,
removePendingStoryAdd,
queryClient,
toast,
setAudioWithAutoPlay,
]);
}
+5 -4
View File
@@ -10,7 +10,7 @@ interface UseModelDownloadToastOptions {
displayName: string;
enabled?: boolean;
onComplete?: () => void;
onError?: (error: string) => void;
onError?: () => void;
}
/**
@@ -101,7 +101,7 @@ export function useModelDownloadToast({
break;
case 'error':
statusIcon = <XCircle className="h-4 w-4 text-destructive" />;
statusText = 'Download failed. See Problems panel for details.';
statusText = `Error: ${progress.error || 'Unknown error'}`;
break;
case 'downloading':
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
@@ -131,7 +131,8 @@ export function useModelDownloadToast({
)}
</div>
),
duration: progress.status === 'complete' || progress.status === 'error' ? 5000 : Infinity,
duration: progress.status === 'complete' ? 5000 : Infinity,
variant: progress.status === 'error' ? 'destructive' : 'default',
});
// Close connection and dismiss toast on completion or error
@@ -168,7 +169,7 @@ export function useModelDownloadToast({
onComplete();
} else if (isError && onError) {
console.log('[useModelDownloadToast] Download error, calling onError callback');
onError(progress.error || 'Unknown error');
onError();
}
}
}
+12 -12
View File
@@ -1,23 +1,23 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { apiClient } from '@/lib/api/client';
import type { ActiveDownloadTask } from '@/lib/api/types';
import { useGenerationStore } from '@/stores/generationStore';
import type { ActiveDownloadTask } from '@/lib/api/types';
// Polling interval in milliseconds
const POLL_INTERVAL = 30000;
const POLL_INTERVAL = 2000;
/**
* Hook to monitor active tasks (downloads and generations).
* Polls the server periodically to catch downloads triggered from anywhere
* (transcription, generation, explicit download, etc.).
*
*
* Returns the active downloads so components can render download toasts.
*/
export function useRestoreActiveTasks() {
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
// Track which downloads we've seen to detect new ones
const seenDownloadsRef = useRef<Set<string>>(new Set());
@@ -25,15 +25,15 @@ export function useRestoreActiveTasks() {
try {
const tasks = await apiClient.getActiveTasks();
// Restore pending generations (e.g., after page refresh)
// Update generation state
if (tasks.generations.length > 0) {
setIsGenerating(true);
setActiveGenerationId(tasks.generations[0].task_id);
for (const gen of tasks.generations) {
addPendingGeneration(gen.task_id);
}
} else {
// Only clear if we were tracking a generation
const currentId = useGenerationStore.getState().activeGenerationId;
if (currentId) {
setIsGenerating(false);
setActiveGenerationId(null);
}
}
@@ -41,14 +41,14 @@ export function useRestoreActiveTasks() {
// Update active downloads
// Keep track of all active downloads (including new ones)
const currentDownloadNames = new Set(tasks.downloads.map((d) => d.model_name));
// Remove completed downloads from our seen set
for (const name of seenDownloadsRef.current) {
if (!currentDownloadNames.has(name)) {
seenDownloadsRef.current.delete(name);
}
}
// Add new downloads to seen set
for (const download of tasks.downloads) {
seenDownloadsRef.current.add(download.model_name);
@@ -59,7 +59,7 @@ export function useRestoreActiveTasks() {
// Silently fail - server might be temporarily unavailable
console.debug('Failed to fetch active tasks:', error);
}
}, [setActiveGenerationId, addPendingGeneration]);
}, [setIsGenerating, setActiveGenerationId]);
useEffect(() => {
// Fetch immediately on mount
+18 -36
View File
@@ -22,11 +22,6 @@ 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 },
@@ -35,39 +30,26 @@ export async function getAudioDuration(
return file.recordedDuration;
}
// 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);
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('error', () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load audio file'));
});
audio.src = url;
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;
});
}
/**
+1 -16
View File
@@ -21,25 +21,10 @@ export function formatDate(date: string | Date): string {
} else {
dateObj = date;
}
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
}
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
qwen: 'Qwen',
luxtts: 'LuxTTS',
chatterbox: 'Chatterbox',
chatterbox_turbo: 'Chatterbox Turbo',
};
export function formatEngineName(engine?: string, modelSize?: string): string {
const name = ENGINE_DISPLAY_NAMES[engine ?? 'qwen'] ?? engine ?? 'Qwen';
if (engine === 'qwen' && modelSize) {
return `${name} ${modelSize}`;
}
return name;
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
+1 -2
View File
@@ -49,9 +49,8 @@ export interface PlatformAudio {
}
export interface PlatformLifecycle {
startServer(remote?: boolean, modelsDir?: string | null): Promise<string>;
startServer(remote?: boolean): Promise<string>;
stopServer(): Promise<void>;
restartServer(modelsDir?: string | null): Promise<string>;
setKeepServerRunning(keep: boolean): Promise<void>;
setupWindowCloseHandler(): Promise<void>;
onServerReady?: () => void;
-5
View File
@@ -8,10 +8,8 @@ import { Sidebar } from '@/components/Sidebar';
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
import { Toaster } from '@/components/ui/toaster';
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
import { useGenerationProgress } from '@/lib/hooks/useGenerationProgress';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
// Simple platform check that works in both web and Tauri
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
@@ -20,9 +18,6 @@ function RootLayout() {
// Monitor active downloads/generations and show toasts for them
const activeDownloads = useRestoreActiveTasks();
// Subscribe to SSE for pending generations — handles completion, auto-play, and history refresh
useGenerationProgress();
return (
<AppFrame>
<div className="flex flex-1 min-h-0 overflow-hidden">
+4 -47
View File
@@ -1,58 +1,15 @@
import { create } from 'zustand';
interface GenerationState {
/** IDs of generations currently in progress */
pendingGenerationIds: Set<string>;
/** Whether any generation is in progress (derived from pendingGenerationIds) */
isGenerating: boolean;
/** Map of generationId → storyId for deferred story additions */
pendingStoryAdds: Map<string, string>;
addPendingGeneration: (id: string) => void;
removePendingGeneration: (id: string) => void;
addPendingStoryAdd: (generationId: string, storyId: string) => void;
removePendingStoryAdd: (generationId: string) => string | undefined;
setActiveGenerationId: (id: string | null) => void;
activeGenerationId: string | null;
setIsGenerating: (generating: boolean) => void;
setActiveGenerationId: (id: string | null) => void;
}
export const useGenerationStore = create<GenerationState>((set, get) => ({
pendingGenerationIds: new Set(),
export const useGenerationStore = create<GenerationState>((set) => ({
isGenerating: false,
activeGenerationId: null,
pendingStoryAdds: new Map(),
addPendingGeneration: (id) =>
set((state) => {
const next = new Set(state.pendingGenerationIds);
next.add(id);
return { pendingGenerationIds: next, isGenerating: true };
}),
removePendingGeneration: (id) =>
set((state) => {
const next = new Set(state.pendingGenerationIds);
next.delete(id);
return { pendingGenerationIds: next, isGenerating: next.size > 0 };
}),
addPendingStoryAdd: (generationId, storyId) =>
set((state) => {
const next = new Map(state.pendingStoryAdds);
next.set(generationId, storyId);
return { pendingStoryAdds: next };
}),
removePendingStoryAdd: (generationId) => {
const storyId = get().pendingStoryAdds.get(generationId);
if (storyId) {
set((state) => {
const next = new Map(state.pendingStoryAdds);
next.delete(generationId);
return { pendingStoryAdds: next };
});
}
return storyId;
},
setIsGenerating: (generating) => set({ isGenerating: generating }),
setActiveGenerationId: (id) => set({ activeGenerationId: id }),
}));
-30
View File
@@ -13,21 +13,6 @@ interface ServerStore {
keepServerRunningOnClose: boolean;
setKeepServerRunningOnClose: (keepRunning: boolean) => void;
maxChunkChars: number;
setMaxChunkChars: (value: number) => void;
crossfadeMs: number;
setCrossfadeMs: (value: number) => void;
normalizeAudio: boolean;
setNormalizeAudio: (value: boolean) => void;
autoplayOnGenerate: boolean;
setAutoplayOnGenerate: (value: boolean) => void;
customModelsDir: string | null;
setCustomModelsDir: (dir: string | null) => void;
}
export const useServerStore = create<ServerStore>()(
@@ -44,21 +29,6 @@ export const useServerStore = create<ServerStore>()(
keepServerRunningOnClose: false,
setKeepServerRunningOnClose: (keepRunning) => set({ keepServerRunningOnClose: keepRunning }),
maxChunkChars: 800,
setMaxChunkChars: (value) => set({ maxChunkChars: value }),
crossfadeMs: 50,
setCrossfadeMs: (value) => set({ crossfadeMs: value }),
normalizeAudio: true,
setNormalizeAudio: (value) => set({ normalizeAudio: value }),
autoplayOnGenerate: true,
setAutoplayOnGenerate: (value) => set({ autoplayOnGenerate: value }),
customModelsDir: null,
setCustomModelsDir: (dir) => set({ customModelsDir: dir }),
}),
{
name: 'voicebox-server',
+9 -12
View File
@@ -334,21 +334,18 @@ 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:17493/profiles \
curl -X POST http://localhost:8000/profiles \
-H "Content-Type: application/json" \
-d '{"name": "My Voice", "language": "en"}'
# Response: {"id": "abc-123", ...}
# 2. Add sample
curl -X POST http://localhost:17493/profiles/abc-123/samples \
curl -X POST http://localhost:8000/profiles/abc-123/samples \
-F "[email protected]" \
-F "reference_text=This is my voice sample"
```
@@ -356,7 +353,7 @@ curl -X POST http://localhost:17493/profiles/abc-123/samples \
### Generating Speech
```bash
curl -X POST http://localhost:17493/generate \
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{
"profile_id": "abc-123",
@@ -368,13 +365,13 @@ curl -X POST http://localhost:17493/generate \
# Response: {"id": "gen-456", "audio_path": "/path/to/audio.wav", ...}
# Download audio
curl http://localhost:17493/audio/gen-456 -o output.wav
curl http://localhost:8000/audio/gen-456 -o output.wav
```
### Transcribing Audio
```bash
curl -X POST http://localhost:17493/transcribe \
curl -X POST http://localhost:8000/transcribe \
-F "[email protected]" \
-F "language=en"
@@ -389,12 +386,12 @@ Add multiple samples to a profile for better quality:
```bash
# Add first sample
curl -X POST http://localhost:17493/profiles/abc-123/samples \
curl -X POST http://localhost:8000/profiles/abc-123/samples \
-F "[email protected]" \
-F "reference_text=First sample"
# Add second sample
curl -X POST http://localhost:17493/profiles/abc-123/samples \
curl -X POST http://localhost:8000/profiles/abc-123/samples \
-F "[email protected]" \
-F "reference_text=Second sample"
@@ -415,10 +412,10 @@ Models are lazy-loaded and can be manually unloaded:
```bash
# Unload TTS model
curl -X POST http://localhost:17493/models/unload
curl -X POST http://localhost:8000/models/unload
# Load specific model size
curl -X POST "http://localhost:17493/models/load?model_size=0.6B"
curl -X POST "http://localhost:8000/models/load?model_size=0.6B"
```
## Error Handling
+1 -1
View File
@@ -1,3 +1,3 @@
# Backend package
__version__ = "0.1.13"
__version__ = "0.1.12"
+12 -58
View File
@@ -4,7 +4,6 @@ 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
@@ -113,73 +112,29 @@ 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",
"chatterbox_turbo": "Chatterbox Turbo",
}
def get_tts_backend() -> TTSBackend:
"""
Get or create the default (Qwen) TTS backend instance based on platform.
Get or create TTS backend instance based on platform.
Returns:
TTS backend instance (MLX or PyTorch)
"""
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.
global _tts_backend
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 _tts_backend is None:
backend_type = get_backend_type()
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()
elif engine == "chatterbox_turbo":
from .chatterbox_turbo_backend import ChatterboxTurboTTSBackend
backend = ChatterboxTurboTTSBackend()
if backend_type == "mlx":
from .mlx_backend import MLXTTSBackend
_tts_backend = MLXTTSBackend()
else:
raise ValueError(f"Unknown TTS engine: {engine}. Supported: {list(TTS_ENGINES.keys())}")
_tts_backends[engine] = backend
return backend
from .pytorch_backend import PyTorchTTSBackend
_tts_backend = PyTorchTTSBackend()
return _tts_backend
def get_stt_backend() -> STTBackend:
@@ -206,7 +161,6 @@ def get_stt_backend() -> STTBackend:
def reset_backends():
"""Reset backend instances (useful for testing)."""
global _tts_backend, _tts_backends, _stt_backend
global _tts_backend, _stt_backend
_tts_backend = None
_tts_backends.clear()
_stt_backend = None
-360
View File
@@ -1,360 +0,0 @@
"""
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
# Load into a local variable first, apply all patches, then
# assign to self.model. This avoids leaving a half-initialised
# model on self.model if any patch step raises an exception.
#
# 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:
model = ChatterboxMultilingualTTS.from_pretrained(
device=device,
)
finally:
torch.load = _orig_torch_load
else:
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 = 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)
# Patch float64 → float32 dtype mismatches in upstream chatterbox.
# librosa.load returns float64 numpy; multiple upstream code paths
# convert it to a torch tensor via torch.from_numpy() without
# casting, then matmul it against float32 model weights.
import types
# Patch S3Tokenizer (used by s3gen.tokenizer)
_tokzr = model.s3gen.tokenizer
_orig_log_mel = _tokzr.log_mel_spectrogram.__func__
def _f32_log_mel(self_tokzr, audio, padding=0):
import torch as _torch
if _torch.is_tensor(audio):
audio = audio.float()
return _orig_log_mel(self_tokzr, audio, padding)
_tokzr.log_mel_spectrogram = types.MethodType(_f32_log_mel, _tokzr)
# Patch VoiceEncoder
_ve = model.ve
_orig_ve_forward = _ve.forward.__func__
def _f32_ve_forward(self_ve, mels):
return _orig_ve_forward(self_ve, mels.float())
_ve.forward = types.MethodType(_f32_ve_forward, _ve)
# All patches applied successfully — publish the model
self.model = model
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)
@@ -1,345 +0,0 @@
"""
Chatterbox Turbo TTS backend implementation.
Wraps ChatterboxTurboTTS from chatterbox-tts for fast, English-only
voice cloning with paralinguistic tag support ([laugh], [cough], etc.).
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_TURBO_HF_REPO = "ResembleAI/chatterbox-turbo"
# Files that must be present for the turbo model
_TURBO_WEIGHT_FILES = [
"t3_turbo_v1.safetensors",
"s3gen_meanflow.safetensors",
"ve.safetensors",
]
class ChatterboxTurboTTSBackend:
"""Chatterbox Turbo TTS backend — fast, English-only, with paralinguistic tags."""
# 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_TURBO_HF_REPO
def _is_model_cached(self, model_size: str = "default") -> bool:
"""Check if the Chatterbox Turbo model is cached locally."""
try:
from huggingface_hub import constants as hf_constants
repo_cache = Path(hf_constants.HF_HUB_CACHE) / (
"models--" + CHATTERBOX_TURBO_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 turbo weight files
snapshots_dir = repo_cache / "snapshots"
if snapshots_dir.exists():
for fname in _TURBO_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 Turbo cache: {e}")
return False
async def load_model(self, model_size: str = "default") -> None:
"""Load the Chatterbox Turbo 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-turbo"
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 Turbo TTS on {device}...")
import torch
from huggingface_hub import snapshot_download
from chatterbox.tts_turbo import ChatterboxTurboTTS
# Download model files ourselves so we can pass token=None
# (upstream from_pretrained passes token=True which requires
# a stored HF token even though the repo is public).
try:
local_path = snapshot_download(
repo_id=CHATTERBOX_TURBO_HF_REPO,
token=None,
allow_patterns=[
"*.safetensors", "*.json", "*.txt", "*.pt", "*.model",
],
)
finally:
tracker_context.__exit__(None, None, None)
# Monkey-patch torch.load for CPU loading. The model's .pt files
# were saved on CUDA; from_local() doesn't pass map_location
# so loading on CPU fails without this.
# Load into a local var, apply patches, then publish to
# self.model so a failed patch doesn't leave us half-initialised.
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 ChatterboxTurboTTSBackend._load_lock:
torch.load = _patched_load
try:
model = ChatterboxTurboTTS.from_local(
local_path, device,
)
finally:
torch.load = _orig_torch_load
else:
model = ChatterboxTurboTTS.from_local(
local_path, device,
)
if not is_cached:
progress_manager.mark_complete(model_name)
task_manager.complete_download(model_name)
# Patch float64 → float32 dtype mismatches in upstream chatterbox.
# librosa.load returns float64 numpy; multiple upstream code paths
# convert it to a torch tensor via torch.from_numpy() without
# casting, then matmul it against float32 model weights.
# We patch the two known entry points:
#
# 1. S3Tokenizer.log_mel_spectrogram — the audio tensor from
# librosa hits _mel_filters (float32) in a matmul.
# 2. VoiceEncoder.forward — float64 mel spectrograms hit the
# float32 LSTM weights.
import types
# Patch S3Tokenizer (used by s3gen.tokenizer)
_tokzr = model.s3gen.tokenizer
_orig_log_mel = _tokzr.log_mel_spectrogram.__func__
def _f32_log_mel(self_tokzr, audio, padding=0):
import torch as _torch
if _torch.is_tensor(audio):
audio = audio.float()
return _orig_log_mel(self_tokzr, audio, padding)
_tokzr.log_mel_spectrogram = types.MethodType(_f32_log_mel, _tokzr)
# Patch VoiceEncoder
_ve = model.ve
_orig_ve_forward = _ve.forward.__func__
def _f32_ve_forward(self_ve, mels):
return _orig_ve_forward(self_ve, mels.float())
_ve.forward = types.MethodType(_f32_ve_forward, _ve)
# Only publish after all patches succeed
self.model = model
logger.info("Chatterbox Turbo 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 Turbo: {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 Turbo 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 Turbo processes reference audio at generation time, so the
prompt just stores the file 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
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 Turbo TTS.
Supports paralinguistic tags in text: [laugh], [cough], [chuckle], etc.
Args:
text: Text to synthesize (may include paralinguistic tags)
voice_prompt: Dict with ref_audio path
language: Ignored (Turbo is English-only)
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
def _generate_sync():
import torch
if seed is not None:
torch.manual_seed(seed)
logger.info("[Chatterbox Turbo] Generating (English)")
wav = self.model.generate(
text,
audio_prompt_path=ref_audio,
temperature=0.8,
top_k=1000,
top_p=0.95,
repetition_penalty=1.2,
)
# 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)
-275
View File
@@ -1,275 +0,0 @@
"""
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)
+9 -51
View File
@@ -5,15 +5,8 @@ MLX backend implementation for TTS and STT using mlx-audio.
from typing import Optional, List, Tuple
import asyncio
import numpy as np
import os
from pathlib import Path
# PATCH: Import and apply offline patch BEFORE any huggingface_hub usage
# This prevents mlx_audio from making network requests when models are cached
from ..utils.hf_offline_patch import patch_huggingface_hub_offline, ensure_original_qwen_config_cached
patch_huggingface_hub_offline()
ensure_original_qwen_config_cached()
from . import TTSBackend, STTBackend
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
from ..utils.audio import normalize_audio, load_audio
@@ -21,12 +14,6 @@ from ..utils.progress import get_progress_manager
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
from ..utils.tasks import get_task_manager
LANGUAGE_CODE_TO_NAME = {
"zh": "chinese", "en": "english", "ja": "japanese", "ko": "korean",
"de": "german", "fr": "french", "ru": "russian", "pt": "portuguese",
"es": "spanish", "it": "italian",
}
class MLXTTSBackend:
"""MLX-based TTS backend using mlx-audio."""
@@ -172,35 +159,15 @@ class MLXTTSBackend:
tracker_context = tracker.patch_download()
tracker_context.__enter__()
# PATCH: Force offline mode when model is already cached
# This prevents crashes when HuggingFace is unreachable
original_hf_hub_offline = os.environ.get("HF_HUB_OFFLINE")
if is_cached:
os.environ["HF_HUB_OFFLINE"] = "1"
print(f"[PATCH] Model {model_size} is cached, forcing HF_HUB_OFFLINE=1 to avoid network requests")
# Import mlx_audio AFTER patching tqdm
from mlx_audio.tts import load
# Load MLX model (downloads automatically)
try:
self.model = load(model_path)
except Exception as load_error:
# If offline mode failed, try with network enabled as fallback
if is_cached and "offline" in str(load_error).lower():
print(f"[PATCH] Offline load failed, trying with network: {load_error}")
os.environ.pop("HF_HUB_OFFLINE", None)
self.model = load(model_path)
else:
raise
finally:
# Exit the patch context
tracker_context.__exit__(None, None, None)
# Restore original HF_HUB_OFFLINE setting
if original_hf_hub_offline is not None:
os.environ["HF_HUB_OFFLINE"] = original_hf_hub_offline
else:
os.environ.pop("HF_HUB_OFFLINE", None)
# Only mark download as complete if we were tracking it
if not is_cached:
@@ -349,8 +316,7 @@ class MLXTTSBackend:
# MLX generate() returns a generator yielding GenerationResult objects
audio_chunks = []
sample_rate = 24000
lang = LANGUAGE_CODE_TO_NAME.get(language, "auto")
# Set seed if provided (MLX uses numpy random)
if seed is not None:
import mlx.core as mx
@@ -378,23 +344,23 @@ class MLXTTSBackend:
sig = inspect.signature(self.model.generate)
if "ref_audio" in sig.parameters:
# Generate with voice cloning
for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text, lang_code=lang):
for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text):
audio_chunks.append(np.array(result.audio))
sample_rate = result.sample_rate
else:
# Fallback: generate without voice cloning
for result in self.model.generate(text, lang_code=lang):
for result in self.model.generate(text):
audio_chunks.append(np.array(result.audio))
sample_rate = result.sample_rate
else:
# No voice prompt, generate normally
for result in self.model.generate(text, lang_code=lang):
for result in self.model.generate(text):
audio_chunks.append(np.array(result.audio))
sample_rate = result.sample_rate
except Exception as e:
# If voice cloning fails, try without it
print(f"Warning: Voice cloning failed, generating without voice prompt: {e}")
for result in self.model.generate(text, lang_code=lang):
for result in self.model.generate(text):
audio_chunks.append(np.array(result.audio))
sample_rate = result.sample_rate
@@ -413,17 +379,9 @@ 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
@@ -444,8 +402,8 @@ class MLXSTTBackend:
"""
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("/", "--"))
model_name = f"openai/whisper-{model_size}"
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
if not repo_cache.exists():
return False
@@ -516,7 +474,7 @@ class MLXSTTBackend:
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}")
model_name = f"openai/whisper-{model_size}"
print(f"Loading MLX Whisper model {model_size}...")
+26 -78
View File
@@ -15,12 +15,6 @@ from ..utils.progress import get_progress_manager
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
from ..utils.tasks import get_task_manager
LANGUAGE_CODE_TO_NAME = {
"zh": "chinese", "en": "english", "ja": "japanese", "ko": "korean",
"de": "german", "fr": "french", "ru": "russian", "pt": "portuguese",
"es": "spanish", "it": "italian",
}
class PyTorchTTSBackend:
"""PyTorch-based TTS backend using Qwen3-TTS."""
@@ -35,23 +29,9 @@ class PyTorchTTSBackend:
"""Get the best available device."""
if torch.cuda.is_available():
return "cuda"
# 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
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
# MPS can have issues, use CPU for stability
return "cpu"
return "cpu"
def is_loaded(self) -> bool:
@@ -186,21 +166,11 @@ class PyTorchTTSBackend:
# 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,
)
self.model = Qwen3TTSModel.from_pretrained(
model_path,
device_map=self.device,
torch_dtype=torch.float32 if self.device == "cpu" else torch.bfloat16,
)
finally:
# Exit the patch context
tracker_context.__exit__(None, None, None)
@@ -365,7 +335,6 @@ class PyTorchTTSBackend:
wavs, sample_rate = self.model.generate_voice_clone(
text=text,
voice_clone_prompt=voice_prompt,
language=LANGUAGE_CODE_TO_NAME.get(language, "auto"),
instruct=instruct,
)
return wavs[0], sample_rate
@@ -376,18 +345,9 @@ 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",
"turbo": "openai/whisper-large-v3-turbo",
}
class PyTorchSTTBackend:
"""PyTorch-based STT backend using Whisper."""
def __init__(self, model_size: str = "base"):
self.model = None
self.processor = None
@@ -398,22 +358,9 @@ class PyTorchSTTBackend:
"""Get the best available device."""
if torch.cuda.is_available():
return "cuda"
# 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
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
# MPS support for Whisper
return "cpu" # Use CPU for stability
return "cpu"
def is_loaded(self) -> bool:
@@ -432,18 +379,18 @@ class PyTorchSTTBackend:
"""
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("/", "--"))
model_name = f"openai/whisper-{model_size}"
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.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():
@@ -454,12 +401,12 @@ class PyTorchSTTBackend:
if not has_weights:
print(f"[_is_model_cached] No model weights found for whisper-{model_size}, treating as not cached")
return False
return True
except Exception as e:
print(f"[_is_model_cached] Error checking cache for whisper-{model_size}: {e}")
return False
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the Whisper model.
@@ -510,7 +457,7 @@ class PyTorchSTTBackend:
# Import transformers
from transformers import WhisperProcessor, WhisperForConditionalGeneration
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
model_name = f"openai/whisper-{model_size}"
print(f"[DEBUG] Model name: {model_name}")
print(f"Loading Whisper model {model_size} on {self.device}...")
@@ -599,20 +546,21 @@ class PyTorchSTTBackend:
)
inputs = inputs.to(self.device)
# Generate transcription
# If language is provided, force it; otherwise let Whisper auto-detect
generate_kwargs = {}
# Set language if provided
forced_decoder_ids = None
if language:
# Support all languages from frontend: en, zh, ja, ko, de, fr, ru, pt, es, it
# Whisper supports these and many more
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
language=language,
task="transcribe",
)
generate_kwargs["forced_decoder_ids"] = forced_decoder_ids
# Generate transcription
with torch.no_grad():
predicted_ids = self.model.generate(
inputs["input_features"],
**generate_kwargs,
forced_decoder_ids=forced_decoder_ids,
)
# Decode
+24 -38
View File
@@ -1,15 +1,11 @@
"""
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
import sys
from pathlib import Path
@@ -18,16 +14,21 @@ def is_apple_silicon():
return platform.system() == "Darwin" and platform.machine() == "arm64"
def build_server(cuda=False):
def build_server(variant="cpu"):
"""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.
variant: 'cpu' for CPU-only build (~500MB) or 'cuda' for CUDA build (~3GB)
"""
backend_dir = Path(__file__).parent
binary_name = 'voicebox-server-cuda' if cuda else 'voicebox-server'
if variant not in ['cpu', 'cuda']:
raise ValueError(f"Invalid variant: {variant}. Must be 'cpu' or 'cuda'")
# Set binary name based on variant
binary_name = f'voicebox-server-{variant}' if variant == 'cuda' else 'voicebox-server'
print(f"Building {variant.upper()} variant: {binary_name}")
# PyInstaller arguments
args = [
@@ -61,7 +62,6 @@ def build_server(cuda=False):
'--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',
@@ -83,16 +83,8 @@ def build_server(cuda=False):
'--collect-submodules', 'jaraco',
])
# 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:
# Add MLX-specific imports if building on Apple Silicon
if is_apple_silicon():
print("Building for Apple Silicon - including MLX dependencies")
args.extend([
'--hidden-import', 'backend.backends.mlx_backend',
@@ -104,15 +96,11 @@ def build_server(cuda=False):
'--hidden-import', 'mlx_audio.stt',
'--collect-submodules', 'mlx',
'--collect-submodules', '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',
# Collect MLX data files including Metal shader libraries (.metallib)
'--collect-data', 'mlx',
'--collect-data', 'mlx_audio',
])
elif not cuda:
else:
print("Building for non-Apple Silicon platform - PyTorch only")
args.extend([
@@ -125,16 +113,14 @@ def build_server(cuda=False):
# Run PyInstaller
PyInstaller.__main__.run(args)
print(f"Binary built in {backend_dir / 'dist' / binary_name}")
print(f"\n{'='*60}")
print(f"Build complete: {variant.upper()} variant")
print(f"Binary: {backend_dir / 'dist' / binary_name}")
print(f"{'='*60}\n")
if __name__ == '__main__':
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)
# Accept variant as command line argument
variant = sys.argv[1] if len(sys.argv) > 1 else 'cpu'
build_server(variant)
+30
View File
@@ -0,0 +1,30 @@
@echo off
REM Build both CPU and CUDA server binaries for Windows
echo ============================================================
echo Building BOTH server binaries (CPU + CUDA)
echo This will take a while...
echo ============================================================
call build_cpu.bat
if errorlevel 1 (
echo CPU build failed!
exit /b 1
)
echo.
echo.
call build_cuda.bat
if errorlevel 1 (
echo CUDA build failed!
exit /b 1
)
echo.
echo ============================================================
echo Both binaries built successfully!
echo ============================================================
echo CPU binary: dist\voicebox-server.exe (~500MB)
echo CUDA binary: dist\voicebox-server-cuda.exe (~3GB)
echo ============================================================
+28
View File
@@ -0,0 +1,28 @@
@echo off
REM Build CPU-only server binary for Windows
REM This creates a ~500MB binary without CUDA support
echo ============================================================
echo Building CPU-only server binary
echo ============================================================
echo.
echo Step 1: Installing CPU-only PyTorch...
pip uninstall -y torch torchvision torchaudio
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
echo.
echo Step 2: Building binary with PyInstaller...
python build_binary.py cpu
echo.
echo Step 3: Restoring CUDA PyTorch for development...
pip uninstall -y torch torchvision torchaudio
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
echo.
echo ============================================================
echo CPU binary built successfully!
echo Location: dist\voicebox-server.exe
echo Size: ~500MB
echo ============================================================
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Build CPU-only server binary
# This creates a ~500MB binary without CUDA support
set -e
echo "============================================================"
echo "Building CPU-only server binary"
echo "============================================================"
echo ""
echo "Step 1: Installing CPU-only PyTorch..."
pip uninstall -y torch torchvision torchaudio || true
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
echo ""
echo "Step 2: Building binary with PyInstaller..."
python build_binary.py cpu
echo ""
echo "Step 3: Restoring CUDA PyTorch for development..."
pip uninstall -y torch torchvision torchaudio || true
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
echo ""
echo "============================================================"
echo "CPU binary built successfully!"
echo "Location: dist/voicebox-server"
echo "Size: ~500MB"
echo "============================================================"
+22
View File
@@ -0,0 +1,22 @@
@echo off
REM Build CUDA server binary for Windows
REM This creates a ~3GB binary with CUDA support
echo ============================================================
echo Building CUDA server binary
echo ============================================================
echo.
echo Step 1: Ensuring CUDA PyTorch is installed...
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 --upgrade
echo.
echo Step 2: Building binary with PyInstaller...
python build_binary.py cuda
echo.
echo ============================================================
echo CUDA binary built successfully!
echo Location: dist\voicebox-server-cuda.exe
echo Size: ~3GB
echo ============================================================
-9
View File
@@ -4,17 +4,8 @@ Configuration module for voicebox backend.
Handles data directory configuration for production bundling.
"""
import os
from pathlib import Path
# Allow users to override the HuggingFace model download directory.
# Set VOICEBOX_MODELS_DIR to an absolute path before starting the server.
# This sets HF_HUB_CACHE so all huggingface_hub downloads go to that path.
_custom_models_dir = os.environ.get("VOICEBOX_MODELS_DIR")
if _custom_models_dir:
os.environ["HF_HUB_CACHE"] = _custom_models_dir
print(f"[config] Model download path set to: {_custom_models_dir}")
# Default data directory (used in development)
_data_dir = Path("data")
-198
View File
@@ -1,198 +0,0 @@
"""
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
+2 -36
View File
@@ -45,14 +45,10 @@ class Generation(Base):
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
text = Column(Text, nullable=False)
language = Column(String, default="en")
audio_path = Column(String, nullable=True)
duration = Column(Float, nullable=True)
audio_path = Column(String, nullable=False)
duration = Column(Float, nullable=False)
seed = Column(Integer)
instruct = Column(Text)
engine = Column(String, default="qwen")
model_size = Column(String, nullable=True)
status = Column(String, default="completed") # generating, completed, failed
error = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
@@ -292,36 +288,6 @@ def _run_migrations(engine):
conn.commit()
print("Added avatar_path column to profiles")
# Migration: Add status and error columns to generations table
if 'generations' in inspector.get_table_names():
columns = {col['name'] for col in inspector.get_columns('generations')}
if 'status' not in columns:
print("Migrating generations: adding status column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generations ADD COLUMN status VARCHAR DEFAULT 'completed'"))
conn.commit()
print("Added status column to generations")
if 'error' not in columns:
print("Migrating generations: adding error column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generations ADD COLUMN error TEXT"))
conn.commit()
print("Added error column to generations")
if 'engine' not in columns:
print("Migrating generations: adding engine column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generations ADD COLUMN engine VARCHAR DEFAULT 'qwen'"))
conn.commit()
print("Added engine column to generations")
# Re-read columns after engine migration (variable name shadows outer `engine`)
columns = {col['name'] for col in inspector.get_columns('generations')}
if 'model_size' not in columns:
print("Migrating generations: adding model_size column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generations ADD COLUMN model_size VARCHAR"))
conn.commit()
print("Added model_size column to generations")
def get_db():
"""Get database session (generator for dependency injection)."""
+1 -42
View File
@@ -29,10 +29,6 @@ async def create_generation(
seed: Optional[int],
db: Session,
instruct: Optional[str] = None,
generation_id: Optional[str] = None,
status: str = "completed",
engine: Optional[str] = "qwen",
model_size: Optional[str] = None,
) -> GenerationResponse:
"""
Create a new generation history entry.
@@ -46,16 +42,12 @@ async def create_generation(
seed: Random seed used (if any)
db: Database session
instruct: Natural language instruction used (if any)
generation_id: Pre-assigned ID (for async generation flow)
status: Generation status (generating, completed, failed)
engine: TTS engine used (qwen, luxtts, chatterbox, chatterbox_turbo)
model_size: Model size variant (1.7B, 0.6B) — only relevant for qwen
Returns:
Created generation entry
"""
db_generation = DBGeneration(
id=generation_id or str(uuid.uuid4()),
id=str(uuid.uuid4()),
profile_id=profile_id,
text=text,
language=language,
@@ -63,9 +55,6 @@ async def create_generation(
duration=duration,
seed=seed,
instruct=instruct,
engine=engine,
model_size=model_size,
status=status,
created_at=datetime.utcnow(),
)
@@ -76,32 +65,6 @@ async def create_generation(
return GenerationResponse.model_validate(db_generation)
async def update_generation_status(
generation_id: str,
status: str,
db: Session,
audio_path: Optional[str] = None,
duration: Optional[float] = None,
error: Optional[str] = None,
) -> Optional[GenerationResponse]:
"""Update the status of a generation (used by async generation flow)."""
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
if not generation:
return None
generation.status = status
if audio_path is not None:
generation.audio_path = audio_path
if duration is not None:
generation.duration = duration
if error is not None:
generation.error = error
db.commit()
db.refresh(generation)
return GenerationResponse.model_validate(generation)
async def get_generation(
generation_id: str,
db: Session,
@@ -180,10 +143,6 @@ async def list_generations(
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
engine=generation.engine or "qwen",
model_size=generation.model_size,
status=generation.status or "completed",
error=generation.error,
created_at=generation.created_at,
))
+113 -1022
View File
File diff suppressed because it is too large Load Diff
+11 -51
View File
@@ -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|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$")
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it)$")
class VoiceProfileResponse(BaseModel):
@@ -52,15 +52,11 @@ class ProfileSampleResponse(BaseModel):
class GenerationRequest(BaseModel):
"""Request model for voice generation."""
profile_id: str
text: str = Field(..., min_length=1, max_length=50000)
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he)$")
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)$")
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|chatterbox_turbo)$")
max_chunk_chars: int = Field(default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting")
crossfade_ms: int = Field(default=50, ge=0, le=500, description="Crossfade duration in ms between chunks (0 for hard cut)")
normalize: bool = Field(default=True, description="Normalize output audio volume")
class GenerationResponse(BaseModel):
@@ -69,14 +65,10 @@ class GenerationResponse(BaseModel):
profile_id: str
text: str
language: str
audio_path: Optional[str] = None
duration: Optional[float] = None
seed: Optional[int] = None
instruct: Optional[str] = None
engine: Optional[str] = "qwen"
model_size: Optional[str] = None
status: str = "completed"
error: Optional[str] = None
audio_path: str
duration: float
seed: Optional[int]
instruct: Optional[str]
created_at: datetime
class Config:
@@ -98,14 +90,10 @@ class HistoryResponse(BaseModel):
profile_name: str
text: str
language: str
audio_path: Optional[str] = None
duration: Optional[float] = None
seed: Optional[int] = None
instruct: Optional[str] = None
engine: Optional[str] = "qwen"
model_size: Optional[str] = None
status: str = "completed"
error: Optional[str] = None
audio_path: str
duration: float
seed: Optional[int]
instruct: Optional[str]
created_at: datetime
class Config:
@@ -139,30 +127,12 @@ 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
@@ -179,21 +149,11 @@ class ModelDownloadRequest(BaseModel):
model_name: str
class ModelMigrateRequest(BaseModel):
"""Request model for migrating models to a new directory."""
destination: str
class ActiveDownloadTask(BaseModel):
"""Response model for active download task."""
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):
+5 -7
View File
@@ -19,17 +19,15 @@ 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 and functional), "pytorch" otherwise
"mlx" on Apple Silicon (if MLX is available), "pytorch" otherwise
"""
if is_apple_silicon():
try:
import mlx.core # noqa: F401 — triggers native lib loading
import mlx
return "mlx"
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.
except ImportError:
# MLX not installed, fallback to PyTorch
return "pytorch"
return "pytorch"
+11 -32
View File
@@ -38,22 +38,14 @@ 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()),
@@ -63,15 +55,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)
@@ -199,37 +191,28 @@ 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)
@@ -344,7 +327,6 @@ 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.
@@ -353,20 +335,17 @@ 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_backend_for_engine(engine)
tts_model = get_tts_model()
if len(samples) == 1:
# Single sample - use directly
+1 -24
View File
@@ -9,38 +9,15 @@ alembic>=1.13.0
# ML models
torch>=2.1.0
transformers>=4.36.0,<=4.57.6
transformers>=4.36.0
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
-22
View File
@@ -64,29 +64,7 @@ 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
+6 -6
View File
@@ -270,14 +270,11 @@ async def add_item_to_story(
generation_created_at=generation.created_at,
)
# Get track from data or default to 0
track = data.track if data.track is not None else 0
# Calculate start_time_ms if not provided
if data.start_time_ms is not None:
start_time_ms = data.start_time_ms
else:
# Find the maximum end time on the target track only
# Find the maximum end time (start_time_ms + duration_ms) of existing items
existing_items = db.query(
DBStoryItem,
DBGeneration
@@ -285,11 +282,11 @@ async def add_item_to_story(
DBGeneration,
DBStoryItem.generation_id == DBGeneration.id
).filter(
DBStoryItem.story_id == story_id,
DBStoryItem.track == track,
DBStoryItem.story_id == story_id
).all()
if not existing_items:
# First item starts at 0
start_time_ms = 0
else:
max_end_time_ms = 0
@@ -300,6 +297,9 @@ async def add_item_to_story(
# Add 200ms gap after the last item
start_time_ms = max_end_time_ms + 200
# Get track from data or default to 0
track = data.track if data.track is not None else 0
# Create item
item = DBStoryItem(
id=str(uuid.uuid4()),
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
Test CUDA binary compression to verify it fits under GitHub's 2GB release asset limit.
Usage:
python test_cuda_compression.py [path/to/voicebox-server-cuda.exe]
If no path provided, looks for the binary in ./dist/
"""
import os
import sys
import subprocess
from pathlib import Path
def format_size(bytes_size):
"""Format bytes into human-readable size."""
for unit in ['B', 'KB', 'MB', 'GB']:
if bytes_size < 1024.0:
return f"{bytes_size:.2f} {unit}"
bytes_size /= 1024.0
return f"{bytes_size:.2f} TB"
def get_file_size(filepath):
"""Get file size in bytes."""
return os.path.getsize(filepath)
def compress_with_7z(input_file, output_file):
"""Compress file using 7z with maximum compression."""
print(f"\nCompressing with 7z (maximum compression)...")
print(f"This may take several minutes for a ~2.5GB file...\n")
cmd = [
'7z', 'a',
'-t7z', # 7z format
'-m0=lzma2', # LZMA2 compression
'-mx=9', # Maximum compression
'-mfb=64', # Fast bytes
'-md=32m', # Dictionary size
'-ms=on', # Solid archive
output_file,
input_file
]
try:
subprocess.run(cmd, check=True, capture_output=True, text=True)
return True
except subprocess.CalledProcessError as e:
print(f"Error during compression: {e}")
print(f"stderr: {e.stderr}")
return False
except FileNotFoundError:
print("ERROR: 7z not found. Please install 7-Zip:")
print(" Windows: https://www.7-zip.org/download.html")
print(" macOS: brew install p7zip")
print(" Linux: apt-get install p7zip-full")
return False
def main():
# Find CUDA binary
if len(sys.argv) > 1:
cuda_binary = Path(sys.argv[1])
else:
# Look in dist directory
dist_dir = Path(__file__).parent / 'dist'
candidates = list(dist_dir.glob('voicebox-server-cuda*.exe'))
if not candidates:
print("ERROR: CUDA binary not found in ./dist/")
print("Please provide the path as an argument:")
print(" python test_cuda_compression.py path/to/voicebox-server-cuda.exe")
sys.exit(1)
cuda_binary = candidates[0]
if not cuda_binary.exists():
print(f"ERROR: File not found: {cuda_binary}")
sys.exit(1)
print("=" * 70)
print("CUDA Binary Compression Test")
print("=" * 70)
# Get original size
original_size = get_file_size(cuda_binary)
print(f"\nOriginal file: {cuda_binary.name}")
print(f"Original size: {format_size(original_size)} ({original_size:,} bytes)")
# Check if already over 2GB
github_limit = 2 * 1024 * 1024 * 1024 # 2GB in bytes
print(f"GitHub limit: {format_size(github_limit)} ({github_limit:,} bytes)")
if original_size > github_limit:
print(f"\n[WARNING] Original file exceeds GitHub limit by {format_size(original_size - github_limit)}")
else:
print(f"\n[OK] Original file is under GitHub limit")
# Compress
output_file = cuda_binary.parent / f"{cuda_binary.stem}.7z"
if output_file.exists():
print(f"\nRemoving existing compressed file: {output_file.name}")
output_file.unlink()
success = compress_with_7z(cuda_binary, output_file)
if not success:
sys.exit(1)
# Check compressed size
compressed_size = get_file_size(output_file)
compression_ratio = (1 - compressed_size / original_size) * 100
print("\n" + "=" * 70)
print("Compression Results")
print("=" * 70)
print(f"\nCompressed file: {output_file.name}")
print(f"Compressed size: {format_size(compressed_size)} ({compressed_size:,} bytes)")
print(f"Compression ratio: {compression_ratio:.1f}%")
print(f"Space saved: {format_size(original_size - compressed_size)}")
if compressed_size <= github_limit:
print(f"\n[SUCCESS] Compressed file fits under GitHub's 2GB limit!")
print(f" Margin: {format_size(github_limit - compressed_size)} remaining")
else:
print(f"\n[FAILED] Compressed file still exceeds GitHub limit")
print(f" Over by: {format_size(compressed_size - github_limit)}")
print(f"\n Alternative: Host on external storage (S3, Azure Blob, etc.)")
print("\n" + "=" * 70)
if __name__ == '__main__':
main()
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
# Test R2 upload locally before running in CI
set -e
echo "============================================================"
echo "Cloudflare R2 Upload Test"
echo "============================================================"
# Check for required environment variables
if [ -z "$AWS_ACCESS_KEY_ID" ] || [ -z "$AWS_SECRET_ACCESS_KEY" ] || [ -z "$R2_ENDPOINT" ]; then
echo "ERROR: Missing required environment variables"
echo ""
echo "Please set:"
echo " export AWS_ACCESS_KEY_ID='your-r2-access-key-id'"
echo " export AWS_SECRET_ACCESS_KEY='your-r2-secret-access-key'"
echo " export R2_ENDPOINT='https://your-account-id.r2.cloudflarestorage.com'"
echo ""
exit 1
fi
# Check for AWS CLI
if ! command -v aws &> /dev/null; then
echo "Installing AWS CLI..."
pip install awscli
fi
# Find CUDA binary
CUDA_BINARY=$(ls dist/voicebox-server-cuda*.exe 2>/dev/null | head -1)
if [ -z "$CUDA_BINARY" ]; then
echo "ERROR: CUDA binary not found in dist/"
echo "Run: bash build_cuda.bat"
exit 1
fi
echo ""
echo "Found CUDA binary: $CUDA_BINARY"
echo "Size: $(du -h "$CUDA_BINARY" | cut -f1)"
echo ""
# Test version
VERSION="v0.1.12-test"
PLATFORM="x86_64-pc-windows-msvc"
FILENAME="voicebox-server-cuda-${PLATFORM}.exe"
echo "Test upload configuration:"
echo " Version: $VERSION"
echo " Platform: $PLATFORM"
echo " Endpoint: $R2_ENDPOINT"
echo " Bucket: voicebox"
echo " Path: cuda/$VERSION/$FILENAME"
echo ""
read -p "Proceed with upload? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 1
fi
echo ""
echo "Uploading to R2..."
aws s3 cp "$CUDA_BINARY" \
"s3://voicebox/cuda/${VERSION}/${FILENAME}" \
--endpoint-url "$R2_ENDPOINT" \
--acl public-read
if [ $? -eq 0 ]; then
echo ""
echo "============================================================"
echo "Upload successful!"
echo "============================================================"
echo ""
echo "Download URL:"
echo "https://downloads.voicebox.sh/cuda/${VERSION}/${FILENAME}"
echo ""
echo "Test with:"
echo "curl -I https://downloads.voicebox.sh/cuda/${VERSION}/${FILENAME}"
echo ""
else
echo ""
echo "Upload failed!"
exit 1
fi
-162
View File
@@ -1,162 +0,0 @@
"""
Tests for CORS origin restrictions.
Validates that the CORS middleware only allows known local origins
and respects the VOICEBOX_CORS_ORIGINS environment variable.
Uses a minimal FastAPI app that mirrors the exact CORS configuration
from backend/main.py, so tests run without heavy ML dependencies.
Usage:
pip install httpx pytest fastapi starlette
python -m pytest backend/tests/test_cors.py -v
"""
import os
import pytest
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.testclient import TestClient
def _build_app(env_origins: str = "") -> FastAPI:
"""
Build a minimal FastAPI app with the same CORS logic as backend/main.py.
This mirrors the exact code in main.py so the test validates the real
configuration without needing torch/numpy/transformers installed.
"""
app = FastAPI()
_default_origins = [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:17493",
"http://127.0.0.1:17493",
"tauri://localhost",
"https://tauri.localhost",
]
_cors_origins = _default_origins + [o.strip() for o in env_origins.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health():
return {"status": "ok"}
return app
@pytest.fixture()
def client():
return TestClient(_build_app())
@pytest.fixture()
def client_with_custom_origins():
return TestClient(_build_app("https://custom.example.com,https://other.example.com"))
def _get_with_origin(client: TestClient, origin: str) -> dict:
"""Send a GET with Origin header, return response headers."""
response = client.get("/health", headers={"Origin": origin})
return dict(response.headers)
def _preflight(client: TestClient, origin: str) -> dict:
"""Send CORS preflight OPTIONS request, return response headers."""
response = client.options(
"/health",
headers={
"Origin": origin,
"Access-Control-Request-Method": "GET",
},
)
return dict(response.headers)
class TestCORSDefaultOrigins:
"""CORS should allow known local origins and block everything else."""
@pytest.mark.parametrize("origin", [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:17493",
"http://127.0.0.1:17493",
"tauri://localhost",
"https://tauri.localhost",
])
def test_allowed_origins(self, client, origin):
headers = _get_with_origin(client, origin)
assert headers.get("access-control-allow-origin") == origin
@pytest.mark.parametrize("origin", [
"http://evil.com",
"http://localhost:9999",
"https://attacker.example.com",
"null",
])
def test_blocked_origins(self, client, origin):
headers = _get_with_origin(client, origin)
assert "access-control-allow-origin" not in headers
def test_preflight_allowed(self, client):
headers = _preflight(client, "http://localhost:5173")
assert headers.get("access-control-allow-origin") == "http://localhost:5173"
def test_preflight_blocked(self, client):
headers = _preflight(client, "http://evil.com")
assert "access-control-allow-origin" not in headers
def test_credentials_header_present(self, client):
headers = _get_with_origin(client, "http://localhost:5173")
assert headers.get("access-control-allow-credentials") == "true"
class TestCORSCustomOrigins:
"""VOICEBOX_CORS_ORIGINS env var should extend the allowlist."""
def test_custom_origin_allowed(self, client_with_custom_origins):
headers = _get_with_origin(client_with_custom_origins, "https://custom.example.com")
assert headers.get("access-control-allow-origin") == "https://custom.example.com"
def test_other_custom_origin_allowed(self, client_with_custom_origins):
headers = _get_with_origin(client_with_custom_origins, "https://other.example.com")
assert headers.get("access-control-allow-origin") == "https://other.example.com"
def test_default_origins_still_work(self, client_with_custom_origins):
headers = _get_with_origin(client_with_custom_origins, "http://localhost:5173")
assert headers.get("access-control-allow-origin") == "http://localhost:5173"
def test_unlisted_origin_still_blocked(self, client_with_custom_origins):
headers = _get_with_origin(client_with_custom_origins, "http://evil.com")
assert "access-control-allow-origin" not in headers
class TestCORSEnvVarParsing:
"""Edge cases for VOICEBOX_CORS_ORIGINS parsing."""
def test_empty_env_var(self):
app = _build_app("")
client = TestClient(app)
headers = _get_with_origin(client, "http://evil.com")
assert "access-control-allow-origin" not in headers
def test_whitespace_trimmed(self):
app = _build_app(" https://spaced.example.com ")
client = TestClient(app)
headers = _get_with_origin(client, "https://spaced.example.com")
assert headers.get("access-control-allow-origin") == "https://spaced.example.com"
def test_trailing_comma_ignored(self):
app = _build_app("https://one.example.com,")
client = TestClient(app)
headers = _get_with_origin(client, "https://one.example.com")
assert headers.get("access-control-allow-origin") == "https://one.example.com"
@@ -1,217 +0,0 @@
"""
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
+8
View File
@@ -32,3 +32,11 @@ 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()
+3 -122
View File
@@ -70,133 +70,14 @@ def save_audio(
sample_rate: int = 24000,
) -> None:
"""
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.
Save audio file.
Args:
audio: Audio array
path: Output path
sample_rate: Sample rate
Raises:
OSError: If file cannot be written
"""
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 (explicit format since .tmp
# extension is not recognised by soundfile)
sf.write(temp_path, audio, sample_rate, format='WAV')
# 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
sf.write(path, audio, sample_rate)
def validate_reference_audio(
-302
View File
@@ -1,302 +0,0 @@
"""
Chunked TTS generation utilities.
Splits long text into sentence-boundary chunks, generates audio per-chunk
via any TTSBackend, and concatenates with crossfade. All logic is
engine-agnostic — it wraps the standard ``TTSBackend.generate()`` interface.
Short text (≤ max_chunk_chars) uses the single-shot fast path with zero
overhead.
"""
import logging
import re
from typing import List, Tuple
import numpy as np
logger = logging.getLogger("voicebox.chunked-tts")
# Default chunk size in characters. Can be overridden per-request via
# the ``max_chunk_chars`` field on GenerationRequest.
DEFAULT_MAX_CHUNK_CHARS = 800
# Common abbreviations that should NOT be treated as sentence endings.
# Lowercase for case-insensitive matching.
_ABBREVIATIONS = frozenset(
{
"mr",
"mrs",
"ms",
"dr",
"prof",
"sr",
"jr",
"st",
"ave",
"blvd",
"inc",
"ltd",
"corp",
"dept",
"est",
"approx",
"vs",
"etc",
"e.g",
"i.e",
"a.m",
"p.m",
"u.s",
"u.s.a",
"u.k",
}
)
# Paralinguistic tags used by Chatterbox Turbo. The splitter must never
# cut inside one of these.
_PARA_TAG_RE = re.compile(r"\[[^\]]*\]")
# ---------------------------------------------------------------------------
# Text splitting
# ---------------------------------------------------------------------------
def split_text_into_chunks(text: str, max_chars: int = DEFAULT_MAX_CHUNK_CHARS) -> List[str]:
"""Split *text* at natural boundaries into chunks of at most *max_chars*.
Priority: sentence-end (``.!?`` not preceded by an abbreviation and not
inside brackets) → clause boundary (``;:,—``) → whitespace → hard cut.
Paralinguistic tags like ``[laugh]`` are treated as atomic and will not
be split across chunks.
"""
text = text.strip()
if not text:
return []
if len(text) <= max_chars:
return [text]
chunks: List[str] = []
remaining = text
while remaining:
remaining = remaining.lstrip()
if not remaining:
break
if len(remaining) <= max_chars:
chunks.append(remaining)
break
segment = remaining[:max_chars]
# Try to split at the last real sentence ending
split_pos = _find_last_sentence_end(segment)
if split_pos == -1:
split_pos = _find_last_clause_boundary(segment)
if split_pos == -1:
split_pos = segment.rfind(" ")
if split_pos == -1:
# Absolute fallback: hard cut but avoid splitting inside a tag
split_pos = _safe_hard_cut(segment, max_chars)
chunk = remaining[: split_pos + 1].strip()
if chunk:
chunks.append(chunk)
remaining = remaining[split_pos + 1 :]
return chunks
def _find_last_sentence_end(text: str) -> int:
"""Return the index of the last sentence-ending punctuation in *text*.
Skips periods that follow common abbreviations (``Dr.``, ``Mr.``, etc.)
and periods inside bracket tags (``[laugh]``). Also handles CJK
sentence-ending punctuation (``。!?``).
"""
best = -1
# ASCII sentence ends
for m in re.finditer(r"[.!?](?:\s|$)", text):
pos = m.start()
char = text[pos]
# Skip periods after abbreviations
if char == ".":
# Walk backwards to find the preceding word
word_start = pos - 1
while word_start >= 0 and text[word_start].isalpha():
word_start -= 1
word = text[word_start + 1 : pos].lower()
if word in _ABBREVIATIONS:
continue
# Skip decimal numbers (digit immediately before the period)
if word_start >= 0 and text[word_start].isdigit():
continue
# Skip if we're inside a bracket tag
if _inside_bracket_tag(text, pos):
continue
best = pos
# CJK sentence-ending punctuation
for m in re.finditer(r"[\u3002\uff01\uff1f]", text):
if m.start() > best:
best = m.start()
return best
def _find_last_clause_boundary(text: str) -> int:
"""Return the index of the last clause-boundary punctuation."""
best = -1
for m in re.finditer(r"[;:,\u2014](?:\s|$)", text):
pos = m.start()
# Skip if inside a bracket tag
if _inside_bracket_tag(text, pos):
continue
best = pos
return best
def _inside_bracket_tag(text: str, pos: int) -> bool:
"""Return True if *pos* falls inside a ``[...]`` tag."""
for m in _PARA_TAG_RE.finditer(text):
if m.start() < pos < m.end():
return True
return False
def _safe_hard_cut(segment: str, max_chars: int) -> int:
"""Find a hard-cut position that doesn't split a ``[tag]``."""
cut = max_chars - 1
# Check if the cut falls inside a bracket tag; if so, move before it
for m in _PARA_TAG_RE.finditer(segment):
if m.start() < cut < m.end():
return m.start() - 1 if m.start() > 0 else cut
return cut
# ---------------------------------------------------------------------------
# Audio concatenation
# ---------------------------------------------------------------------------
def concatenate_audio_chunks(
chunks: List[np.ndarray],
sample_rate: int,
crossfade_ms: int = 50,
) -> np.ndarray:
"""Concatenate audio arrays with a short crossfade to eliminate clicks.
Each chunk is expected to be a 1-D float32 ndarray at *sample_rate* Hz.
"""
if not chunks:
return np.array([], dtype=np.float32)
if len(chunks) == 1:
return chunks[0]
crossfade_samples = int(sample_rate * crossfade_ms / 1000)
result = np.array(chunks[0], dtype=np.float32, copy=True)
for chunk in chunks[1:]:
if len(chunk) == 0:
continue
overlap = min(crossfade_samples, len(result), len(chunk))
if overlap > 0:
fade_out = np.linspace(1.0, 0.0, overlap, dtype=np.float32)
fade_in = np.linspace(0.0, 1.0, overlap, dtype=np.float32)
result[-overlap:] = result[-overlap:] * fade_out + chunk[:overlap] * fade_in
result = np.concatenate([result, chunk[overlap:]])
else:
result = np.concatenate([result, chunk])
return result
# ---------------------------------------------------------------------------
# Engine-agnostic chunked generation
# ---------------------------------------------------------------------------
async def generate_chunked(
backend,
text: str,
voice_prompt: dict,
language: str = "en",
seed: int | None = None,
instruct: str | None = None,
max_chunk_chars: int = DEFAULT_MAX_CHUNK_CHARS,
crossfade_ms: int = 50,
trim_fn=None,
) -> Tuple[np.ndarray, int]:
"""Generate audio with automatic chunking for long text.
For text shorter than *max_chunk_chars* this is a thin wrapper around
``backend.generate()`` with zero overhead.
For longer text the input is split at natural sentence boundaries,
each chunk is generated independently, optionally trimmed (useful for
Chatterbox engines that hallucinate trailing noise), and the results
are concatenated with a crossfade (or hard cut if *crossfade_ms* is 0).
Parameters
----------
backend : TTSBackend
Any backend implementing the ``generate()`` protocol.
text : str
Input text (may be arbitrarily long).
voice_prompt, language, seed, instruct
Forwarded to ``backend.generate()`` verbatim.
max_chunk_chars : int
Maximum characters per chunk (default 800).
crossfade_ms : int
Crossfade duration in milliseconds between chunks. 0 for a hard
cut with no overlap (default 50).
trim_fn : callable | None
Optional ``(audio, sample_rate) -> audio`` post-processing
function applied to each chunk before concatenation (e.g.
``trim_tts_output`` for Chatterbox engines).
Returns
-------
(audio, sample_rate) : Tuple[np.ndarray, int]
"""
chunks = split_text_into_chunks(text, max_chunk_chars)
if len(chunks) <= 1:
# Short text — single-shot fast path
audio, sample_rate = await backend.generate(
text, voice_prompt, language, seed, instruct,
)
if trim_fn is not None:
audio = trim_fn(audio, sample_rate)
return audio, sample_rate
# Long text — chunked generation
logger.info(
"Splitting %d chars into %d chunks (max %d chars each)",
len(text), len(chunks), max_chunk_chars,
)
audio_chunks: List[np.ndarray] = []
sample_rate: int | None = None
for i, chunk_text in enumerate(chunks):
logger.info(
"Generating chunk %d/%d (%d chars)",
i + 1, len(chunks), len(chunk_text),
)
# Vary the seed per chunk to avoid correlated RNG artefacts,
# but keep it deterministic so the same (text, seed) pair
# always produces the same output.
chunk_seed = (seed + i) if seed is not None else None
chunk_audio, chunk_sr = await backend.generate(
chunk_text, voice_prompt, language, chunk_seed, instruct,
)
if trim_fn is not None:
chunk_audio = trim_fn(chunk_audio, chunk_sr)
audio_chunks.append(np.asarray(chunk_audio, dtype=np.float32))
if sample_rate is None:
sample_rate = chunk_sr
audio = concatenate_audio_chunks(audio_chunks, sample_rate, crossfade_ms=crossfade_ms)
return audio, sample_rate
-100
View File
@@ -1,100 +0,0 @@
"""
Monkey patch for huggingface_hub to force offline mode with cached models.
This prevents mlx_audio from making network requests when models are already downloaded.
"""
import os
from pathlib import Path
from typing import Optional, Union
def patch_huggingface_hub_offline():
"""
Monkey-patch huggingface_hub to force offline mode.
This must be called BEFORE importing mlx_audio.
"""
try:
import huggingface_hub
from huggingface_hub import constants as hf_constants
from huggingface_hub.file_download import _try_to_load_from_cache
# Store original function
original_try_load = _try_to_load_from_cache
def _patched_try_to_load_from_cache(
repo_id: str,
filename: str,
cache_dir: Union[str, Path, None] = None,
revision: Optional[str] = None,
repo_type: Optional[str] = None,
):
"""
Patched version that forces offline mode.
Returns None if not cached (instead of making network request).
"""
# Always use the original function, but we're already in HF_HUB_OFFLINE mode
result = original_try_load(
repo_id=repo_id,
filename=filename,
cache_dir=cache_dir,
revision=revision,
repo_type=repo_type,
)
if result is None:
# File not in cache - log this for debugging
cache_path = Path(hf_constants.HF_HUB_CACHE) / f"models--{repo_id.replace('/', '--')}"
print(f"[HF_PATCH] File not cached: {repo_id}/{filename}")
print(f"[HF_PATCH] Expected at: {cache_path}")
else:
print(f"[HF_PATCH] Cache hit: {repo_id}/{filename}")
return result
# Replace the function
import huggingface_hub.file_download as fd
fd._try_to_load_from_cache = _patched_try_to_load_from_cache
print("[HF_PATCH] huggingface_hub patched for offline mode")
except ImportError:
print("[HF_PATCH] huggingface_hub not found, skipping patch")
except Exception as e:
print(f"[HF_PATCH] Error patching huggingface_hub: {e}")
def ensure_original_qwen_config_cached():
"""
The MLX community model is based on the original Qwen model.
mlx_audio may try to fetch config from the original repo.
We need to ensure that config is available in the cache.
"""
from huggingface_hub import constants as hf_constants
# Original Qwen model that mlx_audio might reference
original_repo = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
mlx_repo = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
cache_dir = Path(hf_constants.HF_HUB_CACHE)
original_path = cache_dir / f"models--{original_repo.replace('/', '--')}"
mlx_path = cache_dir / f"models--{mlx_repo.replace('/', '--')}"
# If original repo cache doesn't exist but MLX does, create a symlink or copy config
if not original_path.exists() and mlx_path.exists():
print(f"[HF_PATCH] Original repo not cached, but MLX version is")
print(f"[HF_PATCH] Creating symlink from {original_repo} -> {mlx_repo}")
try:
# Create a symlink so the cache lookup succeeds
original_path.parent.mkdir(parents=True, exist_ok=True)
original_path.symlink_to(mlx_path, target_is_directory=True)
print(f"[HF_PATCH] Symlink created successfully")
except Exception as e:
print(f"[HF_PATCH] Could not create symlink: {e}")
# Auto-apply patch when module is imported
if os.environ.get("VOICEBOX_OFFLINE_PATCH", "1") != "0":
patch_huggingface_hub_offline()
ensure_original_qwen_config_cached()
-9
View File
@@ -72,15 +72,6 @@ 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
+48
View File
@@ -0,0 +1,48 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_data_files
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']
datas += collect_data_files('qwen_tts')
datas += copy_metadata('qwen-tts')
hiddenimports += collect_submodules('qwen_tts')
hiddenimports += collect_submodules('jaraco')
a = Analysis(
['server.py'],
pathex=[],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='voicebox-server-cuda',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+2 -11
View File
@@ -4,26 +4,17 @@ 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.stt']
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']
datas += collect_data_files('qwen_tts')
# 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')
hiddenimports += collect_submodules('mlx')
hiddenimports += collect_submodules('mlx_audio')
a = Analysis(
['server.py'],
pathex=[],
binaries=_mlx_bins + _mlxa_bins,
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
+4 -19
View File
@@ -4,10 +4,6 @@
"workspaces": {
"": {
"name": "voicebox",
"dependencies": {
"loaders.css": "^0.1.2",
"react-loaders": "^3.0.1",
},
"devDependencies": {
"@biomejs/biome": "2.3.12",
"@types/node": "^20.0.0",
@@ -17,7 +13,7 @@
},
"app": {
"name": "@voicebox/app",
"version": "0.1.13",
"version": "0.1.11",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -72,7 +68,7 @@
},
"landing": {
"name": "@voicebox/landing",
"version": "0.1.13",
"version": "0.1.11",
"dependencies": {
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
@@ -97,7 +93,7 @@
},
"tauri": {
"name": "@voicebox/tauri",
"version": "0.1.13",
"version": "0.1.11",
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
@@ -120,7 +116,7 @@
},
"web": {
"name": "@voicebox/web",
"version": "0.1.13",
"version": "0.1.11",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"react": "^18.3.0",
@@ -129,7 +125,6 @@
"zustand": "^4.5.0",
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
@@ -682,8 +677,6 @@
"class-variance-authority": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"classnames": ["[email protected]", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
"client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -880,8 +873,6 @@
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"loaders.css": ["[email protected]", "", {}, "sha512-Rhowlq24ey1VOeor+3wYOt9+MjaxBOJm1u4KlQgNC3+0xJ0LS4wq4iG57D/BPzvuD/7HHDGQOWJ+81oR2EI9bQ=="],
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
@@ -968,8 +959,6 @@
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prop-types": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@@ -980,10 +969,6 @@
"react-hook-form": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="],
"react-is": ["[email protected]", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-loaders": ["[email protected]", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["[email protected]", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
-41
View File
@@ -1,41 +0,0 @@
services:
voicebox:
build: .
container_name: voicebox
restart: unless-stopped
ports:
# Bind to localhost only for security
- "127.0.0.1:17493:17493"
volumes:
# Bind-mount for generated audio (customize the host path as needed)
# Host side: ./output/
# Container side: /app/data/generations/
- ./output:/app/data/generations
# Named volume for profiles, DB, cache (persists across container restarts)
- voicebox-data:/app/data
# HuggingFace model cache (so models aren't re-downloaded on rebuild)
- huggingface-cache:/home/voicebox/.cache/huggingface
environment:
- LOG_LEVEL=info
networks:
- voicebox-net
deploy:
resources:
limits:
cpus: '4'
memory: 8G
networks:
voicebox-net:
driver: bridge
volumes:
voicebox-data:
huggingface-cache:
-70
View File
@@ -1,70 +0,0 @@
# Accessibility: screen reader and keyboard improvements
## Summary
Improvements to support screen reader and keyboard users across the main app surfaces: audio player, generation UI, voice selection, history, voices tab, model management, server tab, and stories.
**Tested with NVDA and Narrator on Windows.**
---
## What changed
### Audio player (after generating audio)
- **Play/Pause, Loop, Mute, Close** – `aria-label` added so each control is announced (e.g. "Play", "Pause", "Loop", "Mute", "Close player").
- **Playback position slider** – `aria-label="Playback position"` and `aria-valuetext` with current/total time (e.g. "0:30 of 2:15").
- **Volume** – Wrapped in a labelled group; volume slider has an associated screen-reader-only label and `aria-valuetext` for the level (e.g. "Volume level, 75%").
### Generation UI (text box and voice choice)
- **Generate speech** (submit) and **Fine-tune instructions** (sliders) – Icon buttons now have `aria-label` (and state for fine-tune, e.g. "Fine-tune instructions, on").
### Voice selection (cards on Generate screen)
- Each **voice card** is focusable (`tabIndex={0}`), has `role="button"`, and an `aria-label` (e.g. "Prashant, en. Select as voice for generation.") with `aria-pressed` when selected.
- **Enter/Space** on the card selects that voice; tab order is card → Export/Edit/Delete.
### History list (generated samples)
- Each **sample row** is focusable with `role="button"` and an `aria-label` (e.g. "Sample from [profile], [duration], [date]. Press Enter to play."); **Enter/Space** plays or restarts.
- **Transcript textarea** has `aria-label` (e.g. "Transcript for sample from [profile], [duration]") so when you focus on the text area, the sample is announced in context.
### Voices tab (table)
- Each **voice row** is focusable with `role="button"` and an `aria-label` (e.g. "[Name], [language], [N] generations, [N] samples. Press Enter to edit."); **Enter/Space** opens edit (except when focus is in a control).
- **Actions** dropdown trigger has `aria-label="Actions for [profile name]"`.
### Model management
- Each **model row** is a focusable region (`tabIndex={0}`, `role="group"`) with an `aria-label` (e.g. "[Model name], [status], [size]. Use Tab to reach Download or Delete.").
- **Download** and **Delete** (and Downloading) buttons have `aria-label` (e.g. "Download [name]", "Delete [name]").
### Server tab (panels)
- **Server Connection**, **Server Status**, and **App Updates** cards are landmarks: `role="region"`, `aria-label`, and `tabIndex={0}` so each panel is focusable and announced (e.g. "Server Connection", "Server Status", "App Updates").
### Stories list
- Each **story row** is a focusable control (`role="button"`, `tabIndex={0}`) with `aria-label` (e.g. "Story [name], [N] items, [date]. Press Enter to select."); **Enter/Space** selects the story. Actions button has `aria-label="Actions for [story name]"`.
### Other controls
- **Story list** – Actions (⋮) button: `aria-label="Actions for [story name]"`.
- **Story track editor** – Play/Pause, Stop, Split, Duplicate, Delete, Zoom in/out: `aria-label` on all icon buttons.
- **Voice profile samples** (SampleList, AudioSampleUpload, AudioSampleRecording, AudioSampleSystem) – Play/Pause and Stop: `aria-label` (e.g. "Play sample", "Pause", "Stop playback").
- **SampleList** mini sample player – Seek slider has `aria-label="Sample playback position"` and `aria-valuetext` for time.
---
## Testing
- **Screen readers:** Tested with **NVDA** and **Narrator** on Windows.
- **Keyboard:** Tab order and Enter/Space activation verified for focusable rows and buttons.
---
## Tech note
- React + TypeScript; Radix UI primitives; labels added via `aria-label`, `aria-labelledby`, `aria-valuetext`, and `role`/`tabIndex` where needed.
- No new dependencies.
+3 -3
View File
@@ -162,7 +162,7 @@ chmod +x voicebox-*.AppImage
**Solutions:**
1. **Check server is running**
```bash
curl http://localhost:17493/health
curl http://localhost:8000/health
```
2. **Check remote mode**
@@ -170,7 +170,7 @@ chmod +x voicebox-*.AppImage
- Check firewall settings
3. **Check port availability**
- The current local app and dev workflow uses port 17493 by default
- Default port is 8000
- 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:17493/openapi.json
curl http://localhost:8000/openapi.json
```
3. **Regenerate client**
+620
View File
@@ -0,0 +1,620 @@
# CUDA Distribution Problem - Complete Analysis
## Table of Contents
1. [Problem Overview](#problem-overview)
2. [Root Cause](#root-cause)
3. [Attempted Solutions](#attempted-solutions)
4. [Current Status](#current-status)
5. [Available Options](#available-options)
6. [Technical Details](#technical-details)
7. [Cost Analysis](#cost-analysis)
8. [Recommendations](#recommendations)
---
## Problem Overview
### Timeline of Issues
**Original Problem (v0.1.0 - v0.1.11)**
- Single server binary with CUDA support
- Size: ~2.9GB
- Issue: MSI installer build fails in GitHub Actions CI
- Error: WiX Toolset cannot handle 3GB files efficiently
**First Solution: Dual Binary System (v0.1.12)**
- Split into CPU (295MB) and CUDA (2.37GB) binaries
- CPU ships with installer
- CUDA as optional download
- Issue: GitHub Release assets have 2GB limit
**Current Problem (Discovered during implementation)**
- GitHub Release Asset Limit: **2GB hard maximum**
- CUDA binary: **2.37GB** (370MB over limit)
- Cannot upload to GitHub Releases
---
## Root Cause
### Why Is The CUDA Binary So Large?
The size difference between CPU and CUDA builds:
| Component | CPU Build | CUDA Build | Difference |
|-----------|-----------|------------|------------|
| PyTorch Core | ~150MB | ~150MB | - |
| CPU Libraries (MKL/OpenBLAS) | ~100MB | - | -100MB |
| CUDA Runtime | - | ~500MB | +500MB |
| cuBLAS | - | ~350MB | +350MB |
| cuDNN | - | ~1.2GB | +1.2GB |
| NVRTC (CUDA Compiler) | - | ~90MB | +90MB |
| Other CUDA libs | - | ~100MB | +100MB |
| **Total** | **~295MB** | **~2.37GB** | **+2.07GB** |
### CUDA Dependencies Breakdown
```
torch/lib/ (CUDA build):
├── cudart64_12.dll (~0.5 MB) - CUDA Runtime
├── cublas64_12.dll (~100 MB) - Basic Linear Algebra
├── cublasLt64_12.dll (~200 MB) - Linear Algebra (optimized)
├── cudnn64_9.dll (~800 MB) - Deep Neural Networks
├── cudnn_*_infer64_9.dll (~400 MB) - DNN Inference ops
├── nvrtc64_*.dll (~50 MB) - Runtime Compiler
├── nvrtc-builtins64_*.dll (~40 MB) - Compiler builtins
├── torch_cuda.dll (~200 MB) - PyTorch CUDA bridge
└── c10_cuda.dll (~20 MB) - Core CUDA utilities
```
**Why These Are Required:**
- cuDNN is essential for neural network operations
- cuBLAS handles all matrix operations (core of ML)
- Cannot split or remove without breaking functionality
---
## Attempted Solutions
### Solution 1: Dual Binary System ✅ (Partially Successful)
**Goal**: Split CPU and CUDA into separate downloads
**Implementation**:
```bash
# Build CPU-only (295MB)
pip install torch --index-url https://download.pytorch.org/whl/cpu
python build_binary.py cpu
# Build CUDA (2.37GB)
pip install torch --index-url https://download.pytorch.org/whl/cu121
python build_binary.py cuda
```
**Results**:
- ✅ CPU binary: 295MB (fits in installer)
- ✅ CI builds successfully
- ✅ Installer size reduced from 3GB to ~500MB
- ❌ CUDA binary still too large for GitHub
**See**: `docs/dual-server-binaries.md`
### Solution 2: Compression Testing ❌ (Failed)
**Goal**: Compress CUDA binary to fit under 2GB
**Method**: 7z with maximum compression settings
```bash
7z a -t7z -m0=lzma2 -mx=9 -mfb=64 -md=32m -ms=on \
voicebox-server-cuda.7z voicebox-server-cuda.exe
```
**Results**:
```
Original: 2.37 GB (2,545,086,396 bytes)
Compressed: 2.35 GB (2,519,381,264 bytes)
Compression: 1.0% (only 24.5MB saved)
GitHub Limit: 2.00 GB (2,147,483,648 bytes)
Over by: 354.67 MB
Status: FAILED - Still exceeds limit by 354MB
```
**Why Compression Failed**:
- CUDA binaries are already optimized machine code
- No redundant data to compress
- Neural network kernels are highly compact
- Libraries are already stripped of debug symbols
**Conclusion**: Compression is not viable
---
## Current Status
### What Works
- ✅ CPU binary builds successfully (295MB)
- ✅ CUDA binary builds successfully (2.37GB)
- ✅ Build scripts for both variants
- ✅ CI workflow updated for dual binaries
- ✅ Installer can be created with CPU binary
### What Doesn't Work
- ❌ Cannot upload CUDA binary to GitHub Releases (exceeds 2GB limit)
- ❌ Compression doesn't reduce size enough
- ❌ No automated distribution path for CUDA binary
### Branch Status
- Branch: `feat/dual-server-binaries`
- Commits: Implementation complete
- Testing: Local builds successful
- Blocker: CUDA distribution path
---
## Available Options
### Option 1: AWS S3 Hosting (Recommended)
**Description**: Host CUDA binary in Amazon S3 bucket
**Pros**:
- ✅ No file size limits (can handle multi-GB files)
- ✅ Fast global CDN (CloudFront)
- ✅ Reliable (99.99% uptime)
- ✅ Pay only for usage
- ✅ Easy CI integration
- ✅ Version control (keep multiple releases)
**Cons**:
- ❌ Requires AWS account
- ❌ Monthly costs (~$1-5/month)
- ❌ Additional infrastructure to manage
**Cost Estimate**:
```
Storage: 2.37 GB × $0.023/GB = $0.05/month
Transfer: 100 downloads × 2.37GB × $0.09/GB = $21.33/month
Total: ~$21-25/month for 100 downloads
~$2-5/month for 10-20 downloads
```
**Implementation**:
```yaml
# .github/workflows/release.yml
- name: Upload CUDA to S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
aws s3 cp backend/cuda-release/voicebox-server-cuda-*.exe \
s3://voicebox-releases/cuda/${{ github.ref_name }}/ \
--acl public-read
# Generate download URL
echo "CUDA_URL=https://voicebox-releases.s3.amazonaws.com/cuda/${{ github.ref_name }}/voicebox-server-cuda-x86_64-pc-windows-msvc.exe" >> release_notes.txt
```
**User Experience**:
1. Install app normally (500MB installer)
2. App detects NVIDIA GPU
3. Shows: "Download CUDA support? (2.4GB)"
4. Downloads from S3: `https://voicebox-releases.s3.amazonaws.com/cuda/v0.1.12/voicebox-server-cuda.exe`
5. Saves to `%APPDATA%/voicebox/binaries/`
6. App restarts with CUDA server
---
### Option 2: Azure Blob Storage
**Description**: Microsoft Azure alternative to S3
**Pros**:
- ✅ Similar to S3 (no size limits, CDN, reliable)
- ✅ Good if already using Azure
- ✅ Competitive pricing
- ✅ Global CDN with Azure CDN
**Cons**:
- ❌ Requires Azure account
- ❌ Similar monthly costs
- ❌ Less common in open source projects
**Cost Estimate**:
```
Storage: $0.018/GB = $0.04/month
Transfer: ~$20-25/month for 100 downloads
```
**Implementation**:
```yaml
- name: Upload to Azure Blob
env:
AZURE_STORAGE_CONNECTION_STRING: ${{ secrets.AZURE_STORAGE }}
run: |
az storage blob upload \
--account-name voiceboxreleases \
--container-name cuda-binaries \
--name v${{ github.ref_name }}/voicebox-server-cuda.exe \
--file backend/cuda-release/voicebox-server-cuda-*.exe \
--tier Hot
```
---
### Option 3: Cloudflare R2
**Description**: Cloudflare's S3-compatible object storage
**Pros**:
- ✅ S3-compatible API
- ✅ **FREE egress (no bandwidth charges!)**
- ✅ Cheaper than S3/Azure
- ✅ Cloudflare CDN included
- ✅ Good for open source projects
**Cons**:
- ❌ Requires Cloudflare account
- ❌ Newer service (less mature than S3)
**Cost Estimate**:
```
Storage: $0.015/GB = $0.04/month
Egress: $0.00 (FREE!)
Class A ops: Negligible
Total: ~$0.04/month (essentially free!)
```
**Why This Is Attractive**:
- Zero bandwidth costs (huge savings)
- Perfect for open source distribution
- S3-compatible (easy migration if needed)
**Implementation**:
Same as S3 (R2 is S3-compatible):
```yaml
- name: Upload to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_ENDPOINT_URL: https://<account-id>.r2.cloudflarestorage.com
run: |
aws s3 cp backend/cuda-release/voicebox-server-cuda-*.exe \
s3://voicebox-releases/cuda/${{ github.ref_name }}/ \
--endpoint-url=$AWS_ENDPOINT_URL
```
---
### Option 4: GitHub Packages (Container Registry)
**Description**: Package CUDA binary as OCI/Docker artifact
**Pros**:
- ✅ Stays in GitHub ecosystem
- ✅ No additional accounts needed
- ✅ Free for public repos
**Cons**:
- ❌ Complex for desktop app distribution
- ❌ Users need to extract from container
- ❌ Awkward UX (not designed for binary distribution)
- ❌ Requires Docker understanding
**Not Recommended**: Containers aren't designed for desktop app binaries
---
### Option 5: Self-Hosted Server
**Description**: Host on your own VPS/server
**Pros**:
- ✅ Full control
- ✅ No cloud provider dependency
- ✅ Predictable costs
**Cons**:
- ❌ Requires server maintenance
- ❌ Bandwidth costs can be high
- ❌ Uptime responsibility
- ❌ Scaling challenges
**Cost Estimate**:
```
VPS: $5-20/month (DigitalOcean, Linode)
Bandwidth: $0.01-0.02/GB
Total: $10-50/month depending on traffic
```
---
### Option 6: Manual Distribution
**Description**: Don't automate - provide manual download instructions
**Pros**:
- ✅ Zero cost
- ✅ Zero infrastructure
- ✅ Simple
**Cons**:
- ❌ Poor user experience
- ❌ Manual upload to file host each release
- ❌ Users must manually download and install
- ❌ No automatic updates for CUDA binary
- ❌ Increases support burden
**Implementation**:
```
Release notes:
"Windows users with NVIDIA GPUs can download CUDA support:
1. Download voicebox-server-cuda.exe from [Google Drive/Mega/etc]
2. Place in C:\Users\<YourName>\AppData\Roaming\voicebox\binaries\
3. Restart the app"
```
**Not Recommended**: Creates friction, support issues
---
### Option 7: Split CUDA Binary
**Description**: Break CUDA binary into multiple <2GB chunks
**Technical Approach**:
```python
# Split binary
split -b 2000M voicebox-server-cuda.exe cuda_part_
# Upload parts to GitHub (each <2GB)
cuda_part_aa (2.0 GB)
cuda_part_ab (0.37 GB)
# App downloads and reassembles
cat cuda_part_* > voicebox-server-cuda.exe
```
**Pros**:
- ✅ Stays on GitHub
- ✅ No external hosting
**Cons**:
- ❌ Complex download logic (multiple files)
- ❌ Integrity checking required
- ❌ More points of failure
- ❌ Users must wait for multiple downloads
- ❌ Still hacky solution
**Complexity**: Medium-High
---
## Technical Details
### Current Build Output
```
backend/dist/
├── voicebox-server.exe 295 MB (CPU-only)
└── voicebox-server-cuda.exe 2.37 GB (CUDA)
# After compression test:
backend/dist/
└── voicebox-server-cuda.7z 2.35 GB (not viable)
```
### CI Workflow Changes Required
For external hosting (S3/R2/Azure):
```yaml
# Current workflow (fails)
- name: Upload CUDA server binary (Windows only)
if: matrix.platform == 'windows-latest'
uses: softprops/action-gh-release@v1
with:
files: backend/cuda-release/voicebox-server-cuda-*.exe # ❌ Fails: >2GB
draft: true
# New workflow (S3 example)
- name: Upload CUDA to S3 (Windows only)
if: matrix.platform == 'windows-latest'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
aws s3 cp backend/cuda-release/voicebox-server-cuda-*.exe \
s3://voicebox-releases/cuda/${{ github.ref_name }}/ \
--acl public-read
# Generate release notes with download URL
cat >> release_notes.md <<EOF
### GPU Acceleration (Windows)
Download CUDA support for NVIDIA GPUs:
[voicebox-server-cuda.exe](https://voicebox-releases.s3.amazonaws.com/cuda/${{ github.ref_name }}/voicebox-server-cuda-x86_64-pc-windows-msvc.exe)
Size: 2.37 GB
EOF
```
### App Changes Required
**Frontend (Tauri)**: Download manager
```typescript
// src/lib/cuda-downloader.ts
const CUDA_DOWNLOAD_URL =
"https://voicebox-releases.s3.amazonaws.com/cuda/v{VERSION}/voicebox-server-cuda.exe";
async function downloadCudaBinary(version: string) {
const url = CUDA_DOWNLOAD_URL.replace("{VERSION}", version);
const savePath = path.join(app.getPath("userData"), "binaries", "voicebox-server-cuda.exe");
// Download with progress
await downloadFile(url, savePath, (progress) => {
// Update UI: "Downloading CUDA support: 45% (1.2GB / 2.4GB)"
});
// Verify checksum
const checksum = await calculateChecksum(savePath);
if (checksum !== EXPECTED_CHECKSUM) {
throw new Error("Download corrupted");
}
}
```
**Backend**: Already supports both binaries (no changes needed)
---
## Cost Analysis
### Monthly Cost Comparison (100 downloads/month)
| Option | Storage | Bandwidth | Total/Month | Notes |
|--------|---------|-----------|-------------|-------|
| **Cloudflare R2** | $0.04 | $0.00 | **$0.04** | Best for open source |
| AWS S3 | $0.05 | $21.33 | $21.38 | Good reliability |
| Azure Blob | $0.04 | $20.00 | $20.04 | Azure ecosystem |
| Self-hosted VPS | $10.00 | $2.37 | $12.37 | Maintenance overhead |
| Manual | $0.00 | $0.00 | $0.00 | Poor UX |
### Annual Cost Comparison
| Option | Year 1 | Year 2+ | Notes |
|--------|--------|---------|-------|
| **Cloudflare R2** | **$0.50** | **$0.50** | Essentially free |
| AWS S3 | $256 | $256 | Predictable |
| Self-hosted | $144 | $144 | Time cost |
**Recommendation**: Cloudflare R2 (free egress = huge savings)
---
## Recommendations
### Recommended Solution: Cloudflare R2
**Why**:
1. **Cost**: Essentially free (~$0.04/month)
2. **Bandwidth**: Zero egress charges (unlimited downloads)
3. **CDN**: Cloudflare's global network included
4. **Compatibility**: S3-compatible API (easy to use)
5. **Perfect for open source**: No surprise bandwidth bills
### Implementation Priority
**Phase 1: Setup (1-2 hours)**
1. Create Cloudflare R2 account
2. Create bucket: `voicebox-releases`
3. Generate API credentials
4. Add to GitHub Secrets
**Phase 2: CI Integration (1-2 hours)**
1. Update `.github/workflows/release.yml`
2. Add R2 upload step
3. Generate release notes with download URL
4. Test with draft release
**Phase 3: App Integration (4-6 hours)**
1. Add GPU detection on startup
2. Implement download manager UI
3. Add progress indicators
4. Implement checksum verification
5. Server restart logic
**Phase 4: Documentation (1 hour)**
1. Update README with GPU instructions
2. Add troubleshooting guide
3. Document manual download process
**Total Time**: ~8-12 hours of development
### Alternative: AWS S3 (If Already Using AWS)
If you're already using AWS for other infrastructure, S3 is also a solid choice:
- More mature than R2
- Extensive documentation
- Familiar tooling
- ~$20/month for moderate usage
---
## Open Questions
1. **Expected Download Volume**: How many CUDA downloads per month?
- Affects cost calculations
- Determines if R2's free egress is significant
2. **Update Strategy**: How to handle CUDA updates?
- Option A: Version in URL path (keep all versions)
- Option B: Overwrite latest (save space)
3. **Fallback Strategy**: What if cloud provider is down?
- Mirror on multiple providers?
- Graceful degradation to CPU?
4. **Telemetry**: Track CUDA download stats?
- Helps with cost forecasting
- User behavior insights
---
## Next Steps
1. **Research Phase** (You are here)
- Evaluate cloud providers
- Check terms of service
- Test account creation
2. **Decision Phase**
- Choose provider (Cloudflare R2 recommended)
- Set up account
- Configure billing alerts
3. **Implementation Phase**
- Update CI workflow
- Implement download manager
- Test end-to-end flow
4. **Launch Phase**
- Deploy to production
- Monitor downloads
- Gather user feedback
---
## References
- **GitHub Release Limits**: https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases
- **Cloudflare R2 Pricing**: https://developers.cloudflare.com/r2/pricing/
- **AWS S3 Pricing**: https://aws.amazon.com/s3/pricing/
- **Compression Test Results**: `backend/test_cuda_compression.py`
- **Dual Binary Implementation**: `docs/dual-server-binaries.md`
---
## Appendix: Alternative Approaches Considered
### A. Dynamic CUDA Loading
**Idea**: Load CUDA DLLs dynamically at runtime
**Why Not**: PyTorch requires CUDA DLLs at import time, can't lazy-load
### B. CUDA as Separate Package
**Idea**: Python package with just CUDA libs
**Why Not**: Still 2GB+, same problem
### C. Model Quantization
**Idea**: Use smaller quantized models
**Why Not**: Doesn't reduce CUDA runtime size
### D. Docker Distribution
**Idea**: Distribute as Docker container
**Why Not**: Poor fit for desktop app, requires Docker installed
---
**Document Version**: 1.0
**Last Updated**: 2026-01-31
**Status**: Research Phase
**Next Review**: After cloud provider decision
+177
View File
@@ -0,0 +1,177 @@
# Dual Server Binary System
## Overview
Voicebox now uses a dual-binary approach to manage the size difference between CPU-only and CUDA-enabled builds:
- **CPU Binary** (~500MB): Ships with the installer by default
- **CUDA Binary** (~3GB): Downloaded on-demand for GPU users
## Problem Solved
Previously, bundling PyTorch with CUDA support created a 3GB server binary, which:
- Made the installer too large (failed CI builds with WiX)
- Forced all users to download CUDA libraries even without NVIDIA GPUs
- Created poor user experience
## Solution
### Build Process
**Two separate binaries are built:**
1. **voicebox-server.exe** (CPU)
- Built with: `pip install torch --index-url https://download.pytorch.org/whl/cpu`
- Size: ~500MB
- Works on all Windows machines
- Included in the installer by default
2. **voicebox-server-cuda.exe** (CUDA)
- Built with: `pip install torch --index-url https://download.pytorch.org/whl/cu121`
- Size: ~3GB
- Requires NVIDIA GPU + drivers
- Uploaded as separate GitHub Release asset
### User Experience
**First Launch:**
1. User installs app (~500MB download)
2. App starts with CPU server
3. If NVIDIA GPU detected:
- Show notification: "Download CUDA support for 4-5x faster inference?"
- User clicks "Download"
- Download voicebox-server-cuda.exe from GitHub (~3GB)
- Save to `%APPDATA%/voicebox/binaries/`
- Restart server with CUDA version
**Settings Panel:**
- Toggle between CPU/CUDA modes
- Download CUDA if not already installed
- Show current inference backend
### Build Scripts
**Windows:**
```bash
cd backend
# Build CPU only
build_cpu.bat
# Build CUDA only
build_cuda.bat
# Build both
build_both.bat
```
**Unix (macOS/Linux):**
```bash
cd backend
# Build CPU only
./build_cpu.sh
```
### CI/CD Workflow
**GitHub Actions (.github/workflows/release.yml):**
1. Install CPU PyTorch
2. Build CPU server → Copy to Tauri binaries
3. Install CUDA PyTorch
4. Build CUDA server → Save for upload
5. Build Tauri app (bundles CPU server)
6. Upload CUDA server as separate release asset
### File Structure
```
Release Assets:
├── Voicebox_0.1.12_x64_en-US.msi (~500MB - includes CPU server)
├── voicebox-server-cuda-x86_64-pc-windows-msvc.exe (~3GB - optional download)
└── latest.json (updater manifest)
```
## Implementation Details
### Modified Files
1. **backend/build_binary.py**
- Added `variant` parameter ('cpu' or 'cuda')
- Outputs different binary names based on variant
2. **backend/build_cpu.bat** (new)
- Installs CPU PyTorch
- Builds CPU binary
- Restores CUDA PyTorch for dev
3. **backend/build_cuda.bat** (new)
- Ensures CUDA PyTorch is installed
- Builds CUDA binary
4. **.github/workflows/release.yml**
- Build CPU binary first (for installer)
- Build CUDA binary second (for upload)
- Upload CUDA binary as additional release asset
- Updated release notes to explain GPU acceleration
### Future Frontend Work
**TODO: Implement CUDA download in the app**
Location: `tauri/src/`
Features needed:
1. GPU detection on startup
2. Download manager for CUDA binary
3. Server binary path switcher
4. Settings UI for CPU/CUDA toggle
5. Progress indicator for 3GB download
API endpoints needed (already exist):
- `/health` - Shows GPU availability
- Server restart mechanism
## Benefits
✓ **Smaller installer**: ~500MB instead of 3GB
✓ **Faster CI builds**: WiX can handle 500MB easily
✓ **User choice**: CPU users don't download unnecessary files
✓ **Better UX**: Optional performance upgrade for GPU users
✓ **Cost savings**: Reduced bandwidth for users without GPUs
## Testing
**Test CPU build:**
```bash
cd backend
python build_binary.py cpu
./dist/voicebox-server.exe --version
```
**Test CUDA build:**
```bash
cd backend
python build_binary.py cuda
./dist/voicebox-server-cuda.exe --version
```
**Verify size:**
```bash
ls -lh backend/dist/
# Should see:
# voicebox-server.exe ~500MB
# voicebox-server-cuda.exe ~3GB
```
**Test server startup:**
```bash
# CPU version
./backend/dist/voicebox-server.exe
# Check logs: Should show CPU inference
# CUDA version (requires NVIDIA GPU)
./backend/dist/voicebox-server-cuda.exe
# Check logs: Should show CUDA inference
```
+122
View File
@@ -0,0 +1,122 @@
# GitHub 2GB Release Asset Limit Issue
## Problem
The CUDA server binary upload fails in CI with:
```
Error: File size (2543828017) is greater than 2 GiB
```
GitHub release assets have a hard limit of 2GB per file. Our CUDA binary is ~2.5GB, which exceeds this limit.
## Background
The dual-server binary system (see `dual-server-binaries.md`) creates two binaries:
- **CPU binary**: ~500MB ✅ Works fine
- **CUDA binary**: ~2.5GB ❌ Exceeds GitHub limit
## Attempted Solution: Compression
We're testing 7z compression with maximum settings to see if we can squeeze the CUDA binary under 2GB.
### Test Script
Run `backend/test_cuda_compression.py` to test compression locally:
```bash
cd backend
python test_cuda_compression.py
```
This will:
1. Find the CUDA binary in `dist/`
2. Compress it with 7z (maximum compression)
3. Report if the compressed size fits under 2GB
### Expected Compression
PyTorch CUDA binaries typically compress well since they contain:
- Repeated patterns in neural network weights
- Debug symbols and metadata
- Redundant CUDA libraries
Estimated compression: 30-40% reduction
- Original: ~2.5GB
- Target: <2GB
- Required compression: >20%
## Fallback: External Hosting
If compression doesn't work, we'll need to host the CUDA binary externally:
### Option 1: AWS S3
```yaml
- name: Upload CUDA binary to S3
run: |
aws s3 cp backend/cuda-release/voicebox-server-cuda-*.exe \
s3://voicebox-releases/cuda-binaries/${{ github.ref_name }}/
```
### Option 2: Azure Blob Storage
```yaml
- name: Upload to Azure Blob
run: |
az storage blob upload \
--account-name voiceboxreleases \
--container-name cuda-binaries \
--file backend/cuda-release/voicebox-server-cuda-*.exe
```
### Option 3: GitHub Packages (Container Registry)
Package as a container image, though this adds complexity for desktop app distribution.
## Implementation Plan
1. **Test compression locally** ← Current step
2. **If compression works (<2GB)**:
- Update CI to compress before upload
- Update app to handle .7z downloads
- Add extraction step in download manager
3. **If compression fails (≥2GB)**:
- Set up external storage (likely S3)
- Update CI to upload to S3
- Provide download URL in release notes
- Update app download manager to fetch from S3
## CI Workflow Changes (if compression works)
```yaml
- name: Compress CUDA binary (Windows only)
if: matrix.platform == 'windows-latest'
shell: bash
run: |
cd backend/cuda-release
7z a -t7z -m0=lzma2 -mx=9 -mfb=64 -md=32m -ms=on \
voicebox-server-cuda-x86_64-pc-windows-msvc.7z \
voicebox-server-cuda-*.exe
- name: Upload compressed CUDA server (Windows only)
if: matrix.platform == 'windows-latest'
uses: softprops/action-gh-release@v1
with:
files: backend/cuda-release/*.7z
```
## User Experience Impact
### With Compression
- Download: `voicebox-server-cuda-*.7z` (~1.5-1.8GB)
- App extracts automatically
- One extra step but manageable
### With External Hosting
- Download from S3/Azure URL
- No GitHub release asset dependency
- Potentially faster download speeds (CDN)
## Status
🔄 **Testing compression locally to determine viability**
Results pending from local test run.
-67
View File
@@ -1,67 +0,0 @@
# Voicebox Issue Pain Points (Snapshot)
## Scope
- Dataset: **128 total issues** (**107 open**, **21 closed**)
- Source: GitHub issues in `jamiepine/voicebox`
- Classification: keyword/theme clustering
- Note: counts below are **non-exclusive** (one issue can belong to multiple pain points)
## Most Common Pain Points (Open Issues)
| Rank | Pain Point | Open Issues | What users are reporting |
|---|---|---:|---|
| 1 | Model download & offline reliability | **32** | Downloads failing/stalling, cache/offline behavior inconsistent, wrong model size selected, Errno issues |
| 2 | GPU/backend compatibility | **22** | GPU not detected, backend fallback surprises, platform-specific runtime failures (Windows/Mac) |
| 3 | Export/save/file persistence | **15** | Export fails, "failed to fetch/download audio", samples/profiles not saving |
| 4 | Language/accent quality & coverage | **14** | Missing language support, accent mismatch, robotic outputs |
| 5 | Update/restart safety + long-op controls | **4** | Auto-restart without warning, update confusion, lack of cancel/pause controls |
## Representative Issues by Pain Point
### 1) Model download & offline reliability (32)
- [#159](https://github.com/jamiepine/voicebox/issues/159) - Qwen download fails with Errno 22
- [#151](https://github.com/jamiepine/voicebox/issues/151) - Model loading hangs / server crashes
- [#150](https://github.com/jamiepine/voicebox/issues/150) - Internet required despite downloaded models
- [#149](https://github.com/jamiepine/voicebox/issues/149) - Cancel/pause controls for large downloads
- [#96](https://github.com/jamiepine/voicebox/issues/96) - 0.6B selection still uses/downloads 1.7B
### 2) GPU/backend compatibility (22)
- [#164](https://github.com/jamiepine/voicebox/issues/164) - Windows: no GPU usage + multiple breakages
- [#141](https://github.com/jamiepine/voicebox/issues/141) - Using CPU only, GPU not used
- [#131](https://github.com/jamiepine/voicebox/issues/131) - Numpy ABI mismatch in bundled app
- [#130](https://github.com/jamiepine/voicebox/issues/130) - Intel Mac tensor/padding generation error
- [#127](https://github.com/jamiepine/voicebox/issues/127) - GPU not found
### 3) Export/save/file persistence (15)
- [#148](https://github.com/jamiepine/voicebox/issues/148) - Japanese export fails on 0.1.12
- [#143](https://github.com/jamiepine/voicebox/issues/143) - Samples not saving
- [#134](https://github.com/jamiepine/voicebox/issues/134) - Can't save profile
- [#105](https://github.com/jamiepine/voicebox/issues/105) - Export audio fails (failed to fetch)
- [#49](https://github.com/jamiepine/voicebox/issues/49) - Export filename/location ignored on Windows
### 4) Language/accent quality & coverage (14)
- [#162](https://github.com/jamiepine/voicebox/issues/162) - Persian audio request/problem
- [#117](https://github.com/jamiepine/voicebox/issues/117) - Arabic language support
- [#113](https://github.com/jamiepine/voicebox/issues/113) - Polish language support
- [#109](https://github.com/jamiepine/voicebox/issues/109) - Ukrainian support
- [#100](https://github.com/jamiepine/voicebox/issues/100) - Non-US accent quality issues
### 5) Update/restart safety + controls (4)
- [#164](https://github.com/jamiepine/voicebox/issues/164) - Update behavior + usability failures
- [#136](https://github.com/jamiepine/voicebox/issues/136) - Auto-restart without warning
- [#86](https://github.com/jamiepine/voicebox/issues/86) - Unexpected restart with no confirmation
- [#149](https://github.com/jamiepine/voicebox/issues/149) - Need pause/cancel and pre-download confirmation
## Additional Signal
- There is also a large **feature-request/misc** bucket (**36 open**) that is competing with stability triage (audiobook, Linux build, additional ASR/TTS models, integrations).
## Takeaway
Most user pain is concentrated in four stability areas: **download/offline path**, **GPU/backend detection**, **save/export reliability**, and **language/accent correctness**. Addressing those first should reduce the majority of current support friction.

Some files were not shown because too many files have changed in this diff Show More