mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-27 06:05:14 -07:00
Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1622e08372 | ||
|
|
dab3344fb5 | ||
|
|
66cdb307e9 | ||
|
|
5d66970fe9 | ||
|
|
637e0b4585 | ||
|
|
d21954358d | ||
|
|
3f633f4a02 | ||
|
|
43241b85cf | ||
|
|
793e392e56 | ||
|
|
73d9b5d70e | ||
|
|
1040625a88 | ||
|
|
6f4503b521 | ||
|
|
f5b6edc2e7 | ||
|
|
8197f0724c | ||
|
|
d40f7d2676 | ||
|
|
99fbcca7f4 | ||
|
|
04f9880c9a | ||
|
|
af7e9814db | ||
|
|
409ec2dbb1 | ||
|
|
3ffbdeed89 | ||
|
|
61dabe7382 | ||
|
|
732d35ca89 | ||
|
|
53b1e8868c | ||
|
|
f090759d8f | ||
|
|
4c4b3e5463 | ||
|
|
e4f3647f9a | ||
|
|
9b07a8480d | ||
|
|
be30a0ac6b | ||
|
|
595747c3d0 | ||
|
|
580179eba3 | ||
|
|
b9c858295d | ||
|
|
3b14f81741 | ||
|
|
6dd5bb2311 | ||
|
|
dcbdf3e89b | ||
|
|
942064912a | ||
|
|
610f64c762 | ||
|
|
a52ff7d950 | ||
|
|
ab10c26ce4 | ||
|
|
ce4269ffa5 | ||
|
|
ec0fb60197 | ||
|
|
ec9402c568 | ||
|
|
d89521559a | ||
|
|
80689ad8ce | ||
|
|
220333b3bb | ||
|
|
e194e95512 | ||
|
|
e796412c2c | ||
|
|
cb541521d2 | ||
|
|
2bc243f93e | ||
|
|
0209008d73 | ||
|
|
9bde534860 | ||
|
|
97eb570b28 | ||
|
|
7d0557a099 | ||
|
|
60a03c56a9 | ||
|
|
d3393fb940 | ||
|
|
07c0aba883 | ||
|
|
77418a52ae | ||
|
|
46f6806e14 | ||
|
|
20851ccc2b | ||
|
|
0b17073345 | ||
|
|
17106b1e40 | ||
|
|
7fcca09f24 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.1.11
|
||||
current_version = 0.1.13
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
|
||||
# Build outputs
|
||||
build/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
target/
|
||||
|
||||
# Keep web/dist for the Docker image
|
||||
!web/dist
|
||||
|
||||
# Development
|
||||
.git/
|
||||
.github/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Data and logs
|
||||
data/
|
||||
*.log
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Documentation
|
||||
docs/
|
||||
landing/
|
||||
mlx-test/
|
||||
|
||||
# Test files
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
*.spec.ts
|
||||
*.spec.tsx
|
||||
|
||||
# Keep these out
|
||||
.env
|
||||
.env.local
|
||||
*.pem
|
||||
*.key
|
||||
credentials.json
|
||||
+254
-25
@@ -4,9 +4,153 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
PROVIDER_VERSION: "1.0.0"
|
||||
|
||||
jobs:
|
||||
# ============================================
|
||||
# Build TTS Providers (uploaded to R2, not GitHub)
|
||||
# ============================================
|
||||
build-providers:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# PyTorch CPU provider (Windows)
|
||||
- platform: "windows-latest"
|
||||
provider: "pytorch-cpu"
|
||||
python-version: "3.12"
|
||||
# PyTorch CUDA provider (Windows) - large binary, uploaded to R2
|
||||
- platform: "windows-latest"
|
||||
provider: "pytorch-cuda"
|
||||
python-version: "3.12"
|
||||
# PyTorch CPU provider (Linux)
|
||||
- platform: "ubuntu-22.04"
|
||||
provider: "pytorch-cpu"
|
||||
python-version: "3.12"
|
||||
# PyTorch CUDA provider (Linux) - large binary, uploaded to R2
|
||||
- platform: "ubuntu-22.04"
|
||||
provider: "pytorch-cuda"
|
||||
python-version: "3.12"
|
||||
# PyTorch CPU provider (macOS Apple Silicon)
|
||||
- platform: "macos-latest"
|
||||
provider: "pytorch-cpu"
|
||||
python-version: "3.12"
|
||||
# PyTorch CPU provider (macOS Intel)
|
||||
- platform: "macos-15-intel"
|
||||
provider: "pytorch-cpu"
|
||||
python-version: "3.12"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y llvm-dev
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "pip"
|
||||
|
||||
- name: Install CPU-only torch (Linux)
|
||||
if: matrix.provider == 'pytorch-cpu' && matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r providers/pytorch-cpu/requirements.txt
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Install Python dependencies (CPU - non-Linux)
|
||||
if: matrix.provider == 'pytorch-cpu' && matrix.platform != 'ubuntu-22.04'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
pip install -r providers/pytorch-cpu/requirements.txt
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Install Python dependencies (CUDA)
|
||||
if: matrix.provider == 'pytorch-cuda'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||
pip install -r providers/pytorch-cuda/requirements.txt
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Build provider binary
|
||||
shell: bash
|
||||
run: |
|
||||
cd providers/${{ matrix.provider }}
|
||||
python build.py
|
||||
|
||||
- name: Package provider for distribution
|
||||
shell: bash
|
||||
run: |
|
||||
cd providers/${{ matrix.provider }}/dist
|
||||
|
||||
# Add platform suffix for archive name
|
||||
if [ "${{ matrix.platform }}" == "windows-latest" ]; then
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-windows.zip"
|
||||
# On Windows, zip the directory
|
||||
powershell Compress-Archive -Path "tts-provider-${{ matrix.provider }}/*" -DestinationPath "$ARCHIVE_NAME"
|
||||
elif [ "${{ matrix.platform }}" == "macos-latest" ]; then
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-arm64.tar.gz"
|
||||
tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/
|
||||
elif [ "${{ matrix.platform }}" == "macos-15-intel" ]; then
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-x64.tar.gz"
|
||||
tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/
|
||||
else
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-linux.tar.gz"
|
||||
tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/
|
||||
fi
|
||||
|
||||
echo "Created archive: $ARCHIVE_NAME"
|
||||
ls -lh "$ARCHIVE_NAME"
|
||||
|
||||
- name: Upload provider to R2
|
||||
shell: bash
|
||||
env:
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
run: |
|
||||
# Install AWS CLI (compatible with R2)
|
||||
pip install awscli
|
||||
|
||||
# Configure AWS CLI for R2
|
||||
aws configure set aws_access_key_id $R2_ACCESS_KEY_ID
|
||||
aws configure set aws_secret_access_key $R2_SECRET_ACCESS_KEY
|
||||
aws configure set region auto
|
||||
|
||||
# Determine archive name based on platform
|
||||
if [ "${{ matrix.platform }}" == "windows-latest" ]; then
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-windows.zip"
|
||||
elif [ "${{ matrix.platform }}" == "macos-latest" ]; then
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-arm64.tar.gz"
|
||||
elif [ "${{ matrix.platform }}" == "macos-15-intel" ]; then
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-x64.tar.gz"
|
||||
else
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-linux.tar.gz"
|
||||
fi
|
||||
|
||||
# Upload to R2 (bucket: voicebox)
|
||||
aws s3 cp "providers/${{ matrix.provider }}/dist/$ARCHIVE_NAME" \
|
||||
"s3://voicebox/providers/v${{ env.PROVIDER_VERSION }}/$ARCHIVE_NAME" \
|
||||
--endpoint-url "$R2_ENDPOINT"
|
||||
|
||||
echo "Uploaded $ARCHIVE_NAME to R2"
|
||||
|
||||
# ============================================
|
||||
# Build Main App (without bundled TTS on Win/Linux)
|
||||
# ============================================
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -14,22 +158,26 @@ 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'
|
||||
# macOS Apple Silicon - MLX bundled (works out of the box)
|
||||
- platform: "macos-latest"
|
||||
args: "--target aarch64-apple-darwin"
|
||||
python-version: "3.12"
|
||||
backend: "mlx"
|
||||
# macOS Intel - PyTorch bundled (smaller user base, keep simple)
|
||||
- platform: "macos-15-intel"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
python-version: "3.12"
|
||||
backend: "pytorch"
|
||||
# Linux - No TTS bundled, providers downloaded separately
|
||||
- platform: "ubuntu-22.04"
|
||||
args: ""
|
||||
python-version: "3.12"
|
||||
backend: "none"
|
||||
# Windows - PyTorch CPU bundled (works out of the box)
|
||||
- platform: "windows-latest"
|
||||
args: ""
|
||||
python-version: "3.12"
|
||||
backend: "pytorch"
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
@@ -40,7 +188,7 @@ jobs:
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev libasound2-dev
|
||||
|
||||
- name: Install LLVM (macOS)
|
||||
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
|
||||
@@ -53,14 +201,24 @@ jobs:
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
cache: "pip"
|
||||
|
||||
- name: Install Python dependencies
|
||||
- name: Install Python dependencies (with TTS)
|
||||
if: matrix.backend != 'none'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Install Python dependencies (without TTS)
|
||||
if: matrix.backend == 'none'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
# Install base requirements without PyTorch/Qwen-TTS
|
||||
pip install fastapi uvicorn sqlalchemy librosa soundfile numpy httpx
|
||||
pip install huggingface_hub # For Whisper downloads
|
||||
|
||||
- name: Install MLX dependencies (Apple Silicon only)
|
||||
if: matrix.backend == 'mlx'
|
||||
run: |
|
||||
@@ -100,7 +258,7 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './tauri/src-tauri -> target'
|
||||
workspaces: "./tauri/src-tauri -> target"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
@@ -136,19 +294,90 @@ 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.
|
||||
|
||||
### Installation
|
||||
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference
|
||||
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference (works out of the box)
|
||||
- **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch
|
||||
- **Windows**: Download the `.msi` installer
|
||||
- **Linux**: Download the `.AppImage` or `.deb` package
|
||||
- **Windows**: Download the `.msi` installer - requires downloading a TTS provider on first use
|
||||
- **Linux**: Download the `.AppImage` or `.deb` package - requires downloading a TTS provider on first use
|
||||
|
||||
### TTS Providers
|
||||
Windows and Linux users will be prompted to download a TTS provider on first launch:
|
||||
- **Windows**: PyTorch CPU (~300MB) or PyTorch CUDA (~2.4GB for NVIDIA GPUs)
|
||||
- **Linux**: PyTorch CUDA (~2.4GB) - requires NVIDIA GPU
|
||||
|
||||
The app includes automatic updates - future updates will be installed automatically.
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
includeUpdaterJson: true
|
||||
|
||||
# ============================================
|
||||
# Build and Push Docker Images
|
||||
# ============================================
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies and build web UI
|
||||
run: |
|
||||
bun install
|
||||
cd web
|
||||
bun run build
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/v* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
else
|
||||
VERSION="dev"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push CPU image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/jamiepine/voicebox:latest
|
||||
ghcr.io/jamiepine/voicebox:${{ steps.version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push CUDA image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.cuda
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
ghcr.io/jamiepine/voicebox:${{ steps.version.outputs.version }}-cuda
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -15,6 +15,7 @@ dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
*.spec
|
||||
target/
|
||||
*.app
|
||||
*.dmg
|
||||
|
||||
@@ -53,6 +53,9 @@ 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
|
||||
|
||||
### Added
|
||||
- **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
|
||||
- Includes Python version detection and compatibility warnings
|
||||
|
||||
+150
-9
@@ -14,16 +14,19 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
||||
### Prerequisites
|
||||
|
||||
- **[Bun](https://bun.sh)** - Fast JavaScript runtime and package manager
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
```
|
||||
|
||||
- **[Python 3.11+](https://python.org)** - For backend development
|
||||
|
||||
```bash
|
||||
python --version # Should be 3.11 or higher
|
||||
```
|
||||
|
||||
- **[Rust](https://rustup.rs)** - For Tauri desktop app (installed automatically by Tauri CLI)
|
||||
|
||||
```bash
|
||||
rustc --version # Check if installed
|
||||
```
|
||||
@@ -37,41 +40,46 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
||||
**Manual setup (required for Windows):**
|
||||
|
||||
1. **Fork and clone the repository**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
||||
cd voicebox
|
||||
```
|
||||
|
||||
2. **Install JavaScript dependencies**
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
This installs dependencies for:
|
||||
|
||||
- `app/` - Shared React frontend
|
||||
- `tauri/` - Tauri desktop wrapper
|
||||
- `web/` - Web deployment wrapper
|
||||
|
||||
3. **Set up Python backend**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
|
||||
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate # On macOS/Linux
|
||||
# or
|
||||
venv\Scripts\activate # On Windows
|
||||
|
||||
|
||||
# Install Python dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
|
||||
# Install MLX dependencies (Apple Silicon only - for faster inference)
|
||||
# On Apple Silicon, this enables native Metal acceleration
|
||||
if [[ $(uname -m) == "arm64" ]]; then
|
||||
pip install -r requirements-mlx.txt
|
||||
fi
|
||||
|
||||
|
||||
# Install Qwen3-TTS (required for voice synthesis)
|
||||
pip install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
```
|
||||
@@ -81,19 +89,24 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
||||
Development requires two terminals: one for the Python backend, one for the Tauri app.
|
||||
|
||||
**Terminal 1: Backend server** (start this first)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate # Activate venv if not already active
|
||||
bun run dev:server
|
||||
# Or manually: uvicorn main:app --reload --port 17493
|
||||
```
|
||||
|
||||
Backend will be available at `http://localhost:17493`
|
||||
|
||||
**Terminal 2: Desktop app**
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
- Create a placeholder sidecar binary (for Tauri compilation)
|
||||
- Start Vite dev server on port 5173
|
||||
- Launch Tauri window pointing to localhost:5173
|
||||
@@ -104,26 +117,133 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
||||
> The bundled server binary is only used in production builds.
|
||||
|
||||
**Optional: Web app**
|
||||
|
||||
```bash
|
||||
bun run dev:web
|
||||
```
|
||||
|
||||
Web app will be available at `http://localhost:5174`
|
||||
|
||||
### Model Downloads
|
||||
|
||||
Models are automatically downloaded from HuggingFace Hub on first use:
|
||||
|
||||
- **Whisper** (transcription): Auto-downloads on first transcription
|
||||
- **Qwen3-TTS** (voice cloning): Auto-downloads on first generation (~2-4GB)
|
||||
|
||||
First-time usage will be slower due to model downloads, but subsequent runs will use cached models.
|
||||
|
||||
### TTS Provider Development
|
||||
|
||||
Voicebox uses a modular provider system to support different inference backends. Understanding this architecture is important when working on TTS features.
|
||||
|
||||
#### Provider Types
|
||||
|
||||
**Bundled Providers** — Included with the app binary:
|
||||
|
||||
- `apple-mlx` — Bundled with macOS Apple Silicon builds (`.dmg` for aarch64)
|
||||
- Uses MLX for native Metal acceleration
|
||||
- Configured in `.github/workflows/release.yml` with `backend: "mlx"`
|
||||
|
||||
**Hybrid Provider:**
|
||||
|
||||
- `pytorch-cpu` — Can be bundled OR downloaded depending on platform
|
||||
- **Bundled** with Windows and macOS Intel builds
|
||||
- macOS Intel: `.dmg` for x64 with `backend: "pytorch"`
|
||||
- Windows: `.exe` installer with PyTorch CPU included
|
||||
- **Downloaded** on first use for Linux builds (~300MB)
|
||||
- Falls back to bundled version if external binary not found
|
||||
|
||||
**External-Only Providers:**
|
||||
|
||||
- `pytorch-cuda` — NVIDIA GPU-accelerated provider (~2.4GB)
|
||||
- Windows/Linux only (no NVIDIA GPUs on macOS)
|
||||
- Downloaded on demand, not bundled
|
||||
- Optional for users with CUDA-capable GPUs
|
||||
|
||||
#### Provider Architecture
|
||||
|
||||
```
|
||||
backend/providers/
|
||||
├── __init__.py # ProviderManager - lifecycle management
|
||||
├── base.py # TTSProvider protocol
|
||||
├── bundled.py # BundledProvider - wraps built-in backends
|
||||
├── local.py # LocalProvider - wraps external subprocess
|
||||
├── installer.py # Download and install external providers
|
||||
└── types.py # Provider type definitions
|
||||
|
||||
providers/
|
||||
├── pytorch-cpu/ # External PyTorch CPU provider
|
||||
│ ├── main.py # FastAPI server
|
||||
│ ├── build.py # PyInstaller build script
|
||||
│ └── build_and_install.py # Build and install locally
|
||||
└── pytorch-cuda/ # External PyTorch CUDA provider
|
||||
├── main.py
|
||||
├── build.py
|
||||
└── build_and_install.py
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. **Bundled providers** run in-process within the main backend
|
||||
2. **External providers** run as separate subprocess servers
|
||||
3. **LocalProvider** communicates with external providers via HTTP
|
||||
4. **ProviderManager** handles starting/stopping and health checks
|
||||
|
||||
#### Building Providers Locally
|
||||
|
||||
When developing provider features, you'll need to build and test external providers:
|
||||
|
||||
**Build a single provider:**
|
||||
|
||||
```bash
|
||||
cd providers/pytorch-cpu
|
||||
python build_and_install.py
|
||||
```
|
||||
|
||||
**Build all providers:**
|
||||
|
||||
```bash
|
||||
bun run build:providers
|
||||
```
|
||||
|
||||
This script:
|
||||
|
||||
- Builds the provider binary with PyInstaller
|
||||
- Detects your platform (Windows/macOS/Linux)
|
||||
- Copies to the correct location:
|
||||
- macOS: `~/Library/Application Support/voicebox/providers/`
|
||||
- Windows: `%APPDATA%\voicebox\providers\`
|
||||
- Linux: `~/.local/share/voicebox/providers/`
|
||||
- Sets executable permissions on Unix
|
||||
|
||||
**Testing provider changes:**
|
||||
|
||||
1. Make changes to `providers/pytorch-cpu/main.py`
|
||||
2. Run `bun run build:providers`
|
||||
3. Restart the Voicebox app
|
||||
4. Select the provider in Settings → TTS Provider
|
||||
|
||||
#### Provider Binary Distribution
|
||||
|
||||
For production releases, provider binaries are:
|
||||
|
||||
1. Built by GitHub Actions for all platforms
|
||||
2. Uploaded to Cloudflare R2 at `downloads.voicebox.sh/providers/v{VERSION}/`
|
||||
3. Downloaded on-demand by users based on their platform and GPU
|
||||
|
||||
See `.github/workflows/release.yml` for the build matrix.
|
||||
|
||||
### Building
|
||||
|
||||
**Build everything (recommended):**
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
This automatically:
|
||||
|
||||
1. Builds the Python server binary (`./scripts/build-server.sh`)
|
||||
2. Builds the Tauri desktop app (`cd tauri && bun run tauri build`)
|
||||
|
||||
@@ -132,13 +252,23 @@ Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src
|
||||
**Note:** The build process detects your platform and includes the appropriate backend (MLX for Apple Silicon, PyTorch for others).
|
||||
|
||||
**Build server binary only:**
|
||||
|
||||
```bash
|
||||
bun run build:server
|
||||
# or
|
||||
./scripts/build-server.sh
|
||||
```
|
||||
|
||||
Creates platform-specific binary in `tauri/src-tauri/binaries/`
|
||||
|
||||
**Build provider binaries (for development):**
|
||||
|
||||
```bash
|
||||
bun run build:providers
|
||||
```
|
||||
|
||||
Builds all external provider binaries and installs them to the system provider directory. See [TTS Provider Development](#tts-provider-development) for details.
|
||||
|
||||
**Building with local Qwen3-TTS development version:**
|
||||
|
||||
If you're actively developing or modifying the Qwen3-TTS library, set the `QWEN_TTS_PATH` environment variable to point to your local clone:
|
||||
@@ -151,34 +281,41 @@ bun run build:server
|
||||
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package. Useful when testing changes to the TTS library before they're published to PyPI or when using an editable install (`pip install -e`).
|
||||
|
||||
**Build web app:**
|
||||
|
||||
```bash
|
||||
cd web
|
||||
bun run build
|
||||
```
|
||||
|
||||
Output in `web/dist/`
|
||||
|
||||
### Generate OpenAPI Client
|
||||
|
||||
After starting the backend server:
|
||||
|
||||
```bash
|
||||
./scripts/generate-api.sh
|
||||
```
|
||||
|
||||
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`
|
||||
|
||||
### Convert Assets to Web Formats
|
||||
|
||||
To optimize images and videos for the web, run:
|
||||
|
||||
```bash
|
||||
bun run convert:assets
|
||||
```
|
||||
|
||||
This script:
|
||||
|
||||
- Converts PNG → WebP (better compression, same quality)
|
||||
- Converts MOV → WebM (VP9 codec, smaller file size)
|
||||
- Processes files in `landing/public/` and `docs/public/`
|
||||
- **Deletes original files** after successful conversion
|
||||
|
||||
**Requirements:** Install `webp` and `ffmpeg`:
|
||||
|
||||
```bash
|
||||
brew install webp ffmpeg
|
||||
```
|
||||
@@ -225,6 +362,7 @@ git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
Then create a pull request on GitHub with:
|
||||
|
||||
- Clear description of changes
|
||||
- Screenshots (for UI changes)
|
||||
- Reference to related issues
|
||||
@@ -370,21 +508,23 @@ Currently, testing is primarily manual. When adding tests:
|
||||
Releases are managed by maintainers:
|
||||
|
||||
1. **Bump version using bumpversion:**
|
||||
|
||||
```bash
|
||||
# Install bumpversion (if not already installed)
|
||||
pip install bumpversion
|
||||
|
||||
|
||||
# Bump patch version (0.1.0 -> 0.1.1)
|
||||
bumpversion patch
|
||||
|
||||
|
||||
# Or bump minor version (0.1.0 -> 0.2.0)
|
||||
bumpversion minor
|
||||
|
||||
|
||||
# Or bump major version (0.1.0 -> 1.0.0)
|
||||
bumpversion major
|
||||
```
|
||||
|
||||
|
||||
This automatically:
|
||||
|
||||
- Updates version numbers in all files (`tauri.conf.json`, `Cargo.toml`, all `package.json` files, `backend/main.py`)
|
||||
- Creates a git commit with the version bump
|
||||
- Creates a git tag (e.g., `v0.1.1`, `v0.2.0`)
|
||||
@@ -392,6 +532,7 @@ Releases are managed by maintainers:
|
||||
2. **Update CHANGELOG.md** with release notes
|
||||
|
||||
3. **Push commits and tags:**
|
||||
|
||||
```bash
|
||||
git push
|
||||
git push --tags
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Base Dockerfile for Voicebox (CPU-only)
|
||||
# For GPU support, use Dockerfile.cuda
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Prevent interactive prompts during build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TZ=UTC
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ffmpeg \
|
||||
curl \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy backend
|
||||
COPY backend/ /app/backend/
|
||||
COPY providers/ /app/providers/
|
||||
|
||||
# Copy pre-built web UI
|
||||
COPY web/dist/ /app/web/dist/
|
||||
|
||||
# Install Python dependencies (without PyTorch - will be downloaded via provider system)
|
||||
RUN python -m pip install --upgrade pip && \
|
||||
pip install --no-cache-dir \
|
||||
fastapi uvicorn[standard] pydantic sqlalchemy alembic \
|
||||
librosa soundfile numpy python-multipart Pillow \
|
||||
huggingface_hub transformers accelerate
|
||||
|
||||
# Create data directory for profiles/generations
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run server with web UI
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Dockerfile for Voicebox with NVIDIA GPU support (CUDA)
|
||||
|
||||
FROM nvidia/cuda:12.1.1-runtime-ubuntu22.04
|
||||
|
||||
# Prevent interactive prompts during build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TZ=UTC
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python 3.12
|
||||
RUN apt-get update && apt-get install -y \
|
||||
software-properties-common \
|
||||
&& add-apt-repository ppa:deadsnakes/ppa \
|
||||
&& apt-get update && apt-get install -y \
|
||||
python3.12 \
|
||||
python3.12-dev \
|
||||
python3.12-venv \
|
||||
ffmpeg \
|
||||
curl \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set Python 3.12 as default and bootstrap pip
|
||||
RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1 && \
|
||||
update-alternatives --install /usr/bin/python python /usr/bin/python3.12 1 && \
|
||||
python3.12 -m ensurepip --upgrade && \
|
||||
python3.12 -m pip install --upgrade pip
|
||||
|
||||
# Copy backend
|
||||
COPY backend/ /app/backend/
|
||||
COPY providers/ /app/providers/
|
||||
|
||||
# Copy pre-built web UI
|
||||
COPY web/dist/ /app/web/dist/
|
||||
|
||||
# Install PyTorch with CUDA support first
|
||||
RUN pip install --no-cache-dir \
|
||||
torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||
|
||||
# Install remaining dependencies
|
||||
RUN pip install --no-cache-dir \
|
||||
fastapi uvicorn[standard] pydantic sqlalchemy alembic \
|
||||
transformers accelerate huggingface_hub \
|
||||
librosa soundfile numpy python-multipart Pillow \
|
||||
qwen-tts
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run server with web UI
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
## What is Voicebox?
|
||||
|
||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as the **Ollama for voice** — download models, clone voices, and generate speech entirely on your machine.
|
||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as a **local, free and open-source alternative to ElevenLabs** — download models, clone voices, and generate speech entirely on your machine.
|
||||
|
||||
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives you:
|
||||
|
||||
@@ -76,16 +76,39 @@ Download a voice model, clone any voice from a few seconds of audio, and compose
|
||||
|
||||
## Download
|
||||
|
||||
Voicebox is available now for macOS and Windows.
|
||||
### Desktop App
|
||||
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| macOS (Apple Silicon) | [voicebox_aarch64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_aarch64.app.tar.gz) |
|
||||
| macOS (Intel) | [voicebox_x64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_x64.app.tar.gz) |
|
||||
| Windows (MSI) | [voicebox_0.1.0_x64_en-US.msi](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_0.1.0_x64_en-US.msi) |
|
||||
| Windows (Setup) | [voicebox_0.1.0_x64-setup.exe](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_0.1.0_x64-setup.exe) |
|
||||
| Platform | Download |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| macOS (Apple Silicon) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||
| macOS (Intel) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||
| Windows (MSI) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||
| Windows (Setup) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||
| Linux (AppImage) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||
| Linux (Deb) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||
|
||||
> **Linux builds coming soon** — Currently blocked by GitHub runner disk space limitations.
|
||||
### Docker
|
||||
|
||||
Run Voicebox with the web UI in Docker - perfect for servers and headless deployments:
|
||||
|
||||
```bash
|
||||
# CPU-only (supports amd64 and arm64)
|
||||
docker run -p 8000:8000 -v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest
|
||||
|
||||
# NVIDIA GPU (recommended for performance)
|
||||
docker run --gpus all -p 8000:8000 -v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
```
|
||||
|
||||
Or use Docker Compose:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open http://localhost:8000 to access the web UI.
|
||||
|
||||
See [Docker Deployment Guide](docs/overview/docker.mdx) for cloud deployments, GPU setup, and more.
|
||||
|
||||
---
|
||||
|
||||
@@ -137,9 +160,10 @@ Create multi-voice narratives, podcasts, and conversations with a timeline-based
|
||||
|
||||
### Flexible Deployment
|
||||
|
||||
- **Local mode** — Everything runs on your machine
|
||||
- **Remote mode** — Connect to a GPU server on your network
|
||||
- **One-click server** — Turn any machine into a Voicebox server
|
||||
- **Desktop app** — Native apps for macOS, Windows, and Linux
|
||||
- **Docker** — Deploy to servers with the web UI included
|
||||
- **Remote mode** — Connect desktop app to a remote GPU server
|
||||
- **Cloud ready** — Deploy to AWS, GCP, DigitalOcean, or any cloud provider
|
||||
|
||||
---
|
||||
|
||||
@@ -176,17 +200,17 @@ Full API documentation available at `http://localhost:8000/docs` when running.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Desktop App | Tauri (Rust) |
|
||||
| Frontend | React, TypeScript, Tailwind CSS |
|
||||
| State | Zustand, React Query |
|
||||
| Backend | FastAPI (Python) |
|
||||
| Voice Model | Qwen3-TTS (PyTorch or MLX) |
|
||||
| Transcription | Whisper (PyTorch or MLX) |
|
||||
| Layer | Technology |
|
||||
| ---------------- | --------------------------------------------------- |
|
||||
| Desktop App | Tauri (Rust) |
|
||||
| Frontend | React, TypeScript, Tailwind CSS |
|
||||
| State | Zustand, React Query |
|
||||
| Backend | FastAPI (Python) |
|
||||
| Voice Model | Qwen3-TTS (PyTorch or MLX) |
|
||||
| Transcription | Whisper (PyTorch or MLX) |
|
||||
| Inference Engine | MLX (Apple Silicon) / PyTorch (Windows/Linux/Intel) |
|
||||
| Database | SQLite |
|
||||
| Audio | WaveSurfer.js, librosa |
|
||||
| Database | SQLite |
|
||||
| Audio | WaveSurfer.js, librosa |
|
||||
|
||||
**Why this stack?**
|
||||
|
||||
@@ -194,6 +218,26 @@ Full API documentation available at `http://localhost:8000/docs` when running.
|
||||
- **FastAPI** — Async Python with automatic OpenAPI schema generation
|
||||
- **Type-safe end-to-end** — Generated TypeScript client from OpenAPI spec
|
||||
|
||||
### TTS Provider Architecture
|
||||
|
||||
Voicebox uses a modular provider system to support different inference backends:
|
||||
|
||||
- **`apple-mlx`** — Bundled with macOS Apple Silicon builds
|
||||
|
||||
- Uses MLX with native Metal acceleration (4-5x faster)
|
||||
- Works out of the box, no download required
|
||||
|
||||
- **`pytorch-cpu`** — Universal CPU provider (bundled or downloaded)
|
||||
|
||||
- Bundled with Windows and macOS Intel builds
|
||||
- Downloaded on first use for Linux (~300MB)
|
||||
|
||||
- **`pytorch-cuda`** — Optional NVIDIA GPU-accelerated provider
|
||||
- Windows/Linux only (~2.4GB)
|
||||
- 4-5x faster inference on CUDA-capable GPUs
|
||||
|
||||
macOS and Windows builds work out of the box with bundled providers. Linux users download a provider on first launch. The app automatically detects your hardware and recommends the best option. All downloadable providers are distributed via Cloudflare R2 for fast, global delivery.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
@@ -202,13 +246,13 @@ Voicebox is the beginning of something bigger. Here's what's coming:
|
||||
|
||||
### Coming Soon
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Real-time Synthesis** | Stream audio as it generates, word by word |
|
||||
| **Conversation Mode** | Multi-speaker dialogues with automatic turn-taking |
|
||||
| **Voice Effects** | Pitch shift, reverb, M3GAN-style effects |
|
||||
| **Timeline Editor** | Audio studio with word-level precision editing |
|
||||
| **More Models** | XTTS, Bark, and other open-source voice models |
|
||||
| Feature | Description |
|
||||
| ----------------------- | -------------------------------------------------- |
|
||||
| **Real-time Synthesis** | Stream audio as it generates, word by word |
|
||||
| **Conversation Mode** | Multi-speaker dialogues with automatic turn-taking |
|
||||
| **Voice Effects** | Pitch shift, reverb, M3GAN-style effects |
|
||||
| **Timeline Editor** | Audio studio with word-level precision editing |
|
||||
| **More Models** | XTTS, Bark, and other open-source voice models |
|
||||
|
||||
### Future Vision
|
||||
|
||||
@@ -260,9 +304,10 @@ cd backend && pip install -r requirements.txt && cd ..
|
||||
bun run dev
|
||||
```
|
||||
|
||||
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org).
|
||||
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org).
|
||||
|
||||
**Performance:**
|
||||
|
||||
**Performance:**
|
||||
- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration for 4-5x faster inference
|
||||
- **Windows/Linux/Intel Mac**: Uses PyTorch backend (CUDA GPU recommended, CPU supported but slower)
|
||||
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -17,6 +17,10 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.4",
|
||||
"@iconify-json/svg-spinners": "^1.2.4",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
@@ -24,6 +28,7 @@
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
@@ -43,7 +48,6 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"framer-motion": "^12.29.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"motion": "^12.29.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
|
||||
+18
-11
@@ -1,13 +1,14 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import ShinyText from '@/components/ShinyText';
|
||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { router } from '@/router';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
const LOADING_MESSAGES = [
|
||||
'Warming up tensors...',
|
||||
@@ -38,6 +39,9 @@ function App() {
|
||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||
const serverStartingRef = useRef(false);
|
||||
|
||||
// Automatically check for app updates on startup and show toast notifications
|
||||
useAutoUpdater(true);
|
||||
|
||||
// Sync stored setting to Rust on startup
|
||||
useEffect(() => {
|
||||
if (platform.metadata.isTauri) {
|
||||
@@ -46,14 +50,18 @@ function App() {
|
||||
console.error('Failed to sync initial setting to Rust:', error);
|
||||
});
|
||||
}
|
||||
}, [platform]);
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||
|
||||
// Setup lifecycle callbacks
|
||||
useEffect(() => {
|
||||
platform.lifecycle.onServerReady = () => {
|
||||
setServerReady(true);
|
||||
};
|
||||
}, [platform]);
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.lifecycle]);
|
||||
|
||||
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||
useEffect(() => {
|
||||
@@ -74,8 +82,7 @@ function App() {
|
||||
console.log('Dev mode: Skipping auto-start of server (run it separately)');
|
||||
setServerReady(true); // Mark as ready so UI doesn't show loading screen
|
||||
// Mark that server was not started by app (so we don't try to stop it on close)
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = false;
|
||||
(window as any).__voiceboxServerStartedByApp = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,14 +102,12 @@ function App() {
|
||||
useServerStore.getState().setServerUrl(serverUrl);
|
||||
setServerReady(true);
|
||||
// Mark that we started the server (so we know to stop it on close)
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = true;
|
||||
(window as any).__voiceboxServerStartedByApp = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to auto-start server:', error);
|
||||
serverStartingRef.current = false;
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = false;
|
||||
(window as any).__voiceboxServerStartedByApp = false;
|
||||
});
|
||||
|
||||
// Cleanup: stop server on actual unmount (not StrictMode remount)
|
||||
@@ -111,7 +116,9 @@ function App() {
|
||||
// Window close event handles server shutdown based on setting
|
||||
serverStartingRef.current = false;
|
||||
};
|
||||
}, [platform]);
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||
|
||||
// Cycle through loading messages every 3 seconds
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { PauseIcon, PlayIcon, RepeatIcon, VolumeHighIcon, VolumeMuteIcon, Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -459,7 +460,7 @@ export function AudioPlayer() {
|
||||
// Use double requestAnimationFrame to ensure DOM is fully rendered
|
||||
let rafId1: number;
|
||||
let rafId2: number;
|
||||
let timeoutId: number | null = null;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
rafId1 = requestAnimationFrame(() => {
|
||||
rafId2 = requestAnimationFrame(() => {
|
||||
@@ -832,7 +833,7 @@ export function AudioPlayer() {
|
||||
className="shrink-0"
|
||||
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={20} className="h-5 w-5" /> : <HugeiconsIcon icon={PlayIcon} size={20} className="h-5 w-5" />}
|
||||
</Button>
|
||||
|
||||
{/* Waveform */}
|
||||
@@ -873,7 +874,7 @@ export function AudioPlayer() {
|
||||
className={isLooping ? 'text-primary' : ''}
|
||||
title="Toggle loop"
|
||||
>
|
||||
<Repeat className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={RepeatIcon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Volume Control */}
|
||||
@@ -884,7 +885,7 @@ export function AudioPlayer() {
|
||||
onClick={() => setVolume(volume > 0 ? 0 : 1)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
||||
{volume > 0 ? <HugeiconsIcon icon={VolumeHighIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={VolumeMuteIcon} size={16} className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Slider
|
||||
value={[volume * 100]}
|
||||
@@ -903,7 +904,7 @@ export function AudioPlayer() {
|
||||
className="shrink-0"
|
||||
title="Close player"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={20} className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { CheckmarkCircle01Icon, CheckmarkCircle02Icon, Edit01Icon, Add01Icon, SpeakerIcon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -135,7 +136,7 @@ export function AudioTab() {
|
||||
<div className="flex items-center justify-between mb-6 shrink-0">
|
||||
<h2 className="text-2xl font-bold">Audio Channels</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
New Channel
|
||||
</Button>
|
||||
</div>
|
||||
@@ -150,13 +151,13 @@ export function AudioTab() {
|
||||
>
|
||||
{allChannels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||
<Speaker className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<HugeiconsIcon icon={SpeakerIcon} size={48} className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No audio channels yet. Create your first channel to route voices to specific
|
||||
devices.
|
||||
</p>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Create Channel
|
||||
</Button>
|
||||
</div>
|
||||
@@ -178,7 +179,7 @@ export function AudioTab() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Speaker className="h-4 w-4 text-muted-foreground" />
|
||||
<HugeiconsIcon icon={SpeakerIcon} size={16} className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
|
||||
@@ -235,7 +236,7 @@ export function AudioTab() {
|
||||
setEditingChannel(channel.id);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Edit01Icon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -248,7 +249,7 @@ export function AudioTab() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -325,10 +326,10 @@ export function AudioTab() {
|
||||
isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30',
|
||||
)}
|
||||
>
|
||||
{isConnected && <Check className="h-3 w-3 text-accent-foreground" />}
|
||||
{isConnected && <HugeiconsIcon icon={CheckmarkCircle01Icon} size={12} className="h-3 w-3 text-accent-foreground" />}
|
||||
</div>
|
||||
) : device.is_default ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} size={16} className="h-4 w-4 text-primary shrink-0" />
|
||||
) : null}
|
||||
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
|
||||
{device.name}
|
||||
@@ -339,7 +340,7 @@ export function AudioTab() {
|
||||
</div>
|
||||
) : (
|
||||
<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" />
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} size={48} 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'}
|
||||
</p>
|
||||
@@ -494,7 +495,7 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={12} className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -602,7 +603,7 @@ function EditChannelDialog({
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={12} className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -648,7 +649,7 @@ function EditChannelDialog({
|
||||
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={12} className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SparklesIcon, TextSquareIcon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useMatchRoute } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
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';
|
||||
@@ -187,7 +189,7 @@ export function FloatingGenerateBox({
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 overflow-hidden p-3"
|
||||
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 p-3"
|
||||
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
||||
>
|
||||
<Form {...form}>
|
||||
@@ -274,7 +276,7 @@ export function FloatingGenerateBox({
|
||||
field.ref(node);
|
||||
}
|
||||
}}
|
||||
placeholder="Add delivery instructions..."
|
||||
placeholder="e.g. very happy and excited"
|
||||
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
||||
style={{
|
||||
minHeight: isExpanded ? '100px' : '32px',
|
||||
@@ -294,18 +296,27 @@ export function FloatingGenerateBox({
|
||||
</motion.div>
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !selectedProfileId}
|
||||
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
|
||||
size="icon"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !selectedProfileId}
|
||||
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg transition-all duration-200"
|
||||
size="icon"
|
||||
>
|
||||
{isPending ? (
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={SparklesIcon} size={16} className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
{isPending
|
||||
? 'Generating...'
|
||||
: !selectedProfileId
|
||||
? 'Select a voice profile first'
|
||||
: 'Generate speech'}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
@@ -315,20 +326,25 @@ export function FloatingGenerateBox({
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute top-0 right-[calc(100%+0.5rem)]"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsInstructMode(!isInstructMode)}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-full transition-all duration-200',
|
||||
isInstructMode
|
||||
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||
: 'bg-card border border-border hover:bg-background/50',
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsInstructMode(!isInstructMode)}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-full transition-all duration-200',
|
||||
isInstructMode
|
||||
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||
: 'bg-card border border-border hover:bg-background/50',
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon icon={TextSquareIcon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
Fine tune instructions
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Loader2, Mic } from 'lucide-react';
|
||||
import { Mic01Icon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -46,7 +48,11 @@ export function GenerationForm() {
|
||||
<FormLabel>Voice Profile</FormLabel>
|
||||
{selectedProfile ? (
|
||||
<div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
|
||||
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||
<HugeiconsIcon
|
||||
icon={Mic01Icon}
|
||||
size={16}
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<span className="font-medium">{selectedProfile.name}</span>
|
||||
<span className="text-sm text-muted-foreground">{selectedProfile.language}</span>
|
||||
</div>
|
||||
@@ -170,14 +176,10 @@ 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" />
|
||||
<Icon icon="svg-spinners:ring-resize" className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { AudioWaveform, Download, FileArchive, Loader2, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Archive01Icon,
|
||||
Delete01Icon,
|
||||
Download01Icon,
|
||||
MoreHorizontalIcon,
|
||||
PlayIcon,
|
||||
WaveIcon,
|
||||
} from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { HistoryResponse } from '@/lib/api/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,6 +27,7 @@ import {
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { HistoryResponse } from '@/lib/api/types';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import {
|
||||
useDeleteGeneration,
|
||||
@@ -46,11 +55,17 @@ 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 { data: historyData, isLoading, isFetching } = useHistory({
|
||||
const {
|
||||
data: historyData,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useHistory({
|
||||
limit,
|
||||
offset: page * limit,
|
||||
});
|
||||
@@ -210,7 +225,10 @@ export function HistoryTable() {
|
||||
if (isLoading && page === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<Icon
|
||||
icon="svg-spinners:ring-resize"
|
||||
className="h-8 w-8 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -256,7 +274,11 @@ export function HistoryTable() {
|
||||
>
|
||||
{/* Waveform icon */}
|
||||
<div className="flex items-center shrink-0">
|
||||
<AudioWaveform className="h-5 w-5 text-muted-foreground" />
|
||||
<HugeiconsIcon
|
||||
icon={WaveIcon}
|
||||
size={20}
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Left side - Meta information */}
|
||||
@@ -280,6 +302,7 @@ export function HistoryTable() {
|
||||
<Textarea
|
||||
value={gen.text}
|
||||
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -297,36 +320,35 @@ export function HistoryTable() {
|
||||
className="h-8 w-8"
|
||||
aria-label="Actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} 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" />
|
||||
<HugeiconsIcon icon={PlayIcon} size={16} 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" />
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} 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" />
|
||||
<HugeiconsIcon icon={Archive01Icon} size={16} 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" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -339,7 +361,12 @@ export function HistoryTable() {
|
||||
{/* Load more trigger element */}
|
||||
{hasMore && (
|
||||
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
|
||||
{isFetching && <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />}
|
||||
{isFetching && (
|
||||
<Icon
|
||||
icon="svg-spinners:ring-resize"
|
||||
className="h-6 w-6 animate-spin text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -358,7 +385,8 @@ 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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Sparkles, Upload } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { SparklesIcon, Upload01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useRef, useState } from 'react';
|
||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||
@@ -89,7 +90,7 @@ export function MainEditor() {
|
||||
<h2 className="text-2xl font-bold">Voicebox</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleImportClick}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Upload01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Import Voice
|
||||
</Button>
|
||||
<input
|
||||
@@ -100,7 +101,7 @@ export function MainEditor() {
|
||||
className="hidden"
|
||||
/>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={SparklesIcon} size={16} className="mr-2 h-4 w-4" />
|
||||
Create Voice
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Folder01Icon, FolderOpenIcon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useSystemFolders } from '@/lib/hooks/useSystemFolders';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
interface FolderRowProps {
|
||||
label: string;
|
||||
description: string;
|
||||
path: string | undefined;
|
||||
isLoading: boolean;
|
||||
canOpen: boolean;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
function FolderRow({ label, description, path, isLoading, canOpen, onOpen }: FolderRowProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<div className="text-xs text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
{canOpen && path && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onOpen}
|
||||
disabled={isLoading || !path}
|
||||
className="shrink-0"
|
||||
>
|
||||
<HugeiconsIcon icon={FolderOpenIcon} size={16} className="h-4 w-4 mr-2" />
|
||||
Open
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={isLoading ? 'Loading...' : path || 'Not available'}
|
||||
readOnly
|
||||
className="font-mono text-xs text-muted-foreground select-all cursor-text"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataFolders() {
|
||||
const { data: folders, isLoading, error } = useSystemFolders();
|
||||
const platform = usePlatform();
|
||||
const isTauri = platform.metadata.isTauri;
|
||||
|
||||
const handleOpenFolder = async (path: string | undefined) => {
|
||||
if (!path) return;
|
||||
const success = await platform.filesystem.openFolder(path);
|
||||
if (!success && isTauri) {
|
||||
console.error('Failed to open folder:', path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HugeiconsIcon icon={Folder01Icon} size={20} className="h-5 w-5" />
|
||||
Data Folders
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isTauri
|
||||
? 'Click "Open" to view folders in your file explorer, or copy the paths below.'
|
||||
: 'These are the server-side folder paths where your data is stored.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error ? (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<Icon icon="lucide:alert-circle" className="h-4 w-4" />
|
||||
<span>Failed to load folder paths: {error.message}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FolderRow
|
||||
label="App Data"
|
||||
description="Voices, generations, and app database"
|
||||
path={folders?.data_dir}
|
||||
isLoading={isLoading}
|
||||
canOpen={isTauri}
|
||||
onOpen={() => handleOpenFolder(folders?.data_dir)}
|
||||
/>
|
||||
<FolderRow
|
||||
label="Models"
|
||||
description="Downloaded AI models from HuggingFace Hub"
|
||||
path={folders?.models_dir}
|
||||
isLoading={isLoading}
|
||||
canOpen={isTauri}
|
||||
onOpen={() => handleOpenFolder(folders?.models_dir)}
|
||||
/>
|
||||
<FolderRow
|
||||
label="Providers"
|
||||
description="External TTS provider binaries (PyTorch CPU/CUDA)"
|
||||
path={folders?.providers_dir}
|
||||
isLoading={isLoading}
|
||||
canOpen={isTauri}
|
||||
onOpen={() => handleOpenFolder(folders?.providers_dir)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Delete01Icon, Download01Icon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Download, Loader2, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -17,7 +19,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import { ModelProgress } from './ModelProgress';
|
||||
|
||||
export function ModelManagement() {
|
||||
const { toast } = useToast();
|
||||
@@ -27,15 +28,36 @@ export function ModelManagement() {
|
||||
|
||||
const { data: modelStatus, isLoading } = useQuery({
|
||||
queryKey: ['modelStatus'],
|
||||
queryFn: () => apiClient.getModelStatus(),
|
||||
queryFn: async () => {
|
||||
console.log('[Query] Fetching model status');
|
||||
const result = await apiClient.getModelStatus();
|
||||
console.log('[Query] Model status fetched:', result);
|
||||
return result;
|
||||
},
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
});
|
||||
|
||||
// Callbacks for download completion
|
||||
const handleDownloadComplete = useCallback(() => {
|
||||
console.log('[ModelManagement] Download complete, clearing state');
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
}, [queryClient]);
|
||||
|
||||
const handleDownloadError = useCallback(() => {
|
||||
console.log('[ModelManagement] Download error, clearing state');
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
}, []);
|
||||
|
||||
// Use progress toast hook for the downloading model
|
||||
useModelDownloadToast({
|
||||
modelName: downloadingModel || '',
|
||||
displayName: downloadingDisplayName || '',
|
||||
enabled: !!downloadingModel && !!downloadingDisplayName,
|
||||
onComplete: handleDownloadComplete,
|
||||
onError: handleDownloadError,
|
||||
});
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@@ -45,44 +67,69 @@ export function ModelManagement() {
|
||||
sizeMb?: number;
|
||||
} | null>(null);
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: (modelName: string) => {
|
||||
const handleDownload = async (modelName: string) => {
|
||||
console.log('[Download] Button clicked for:', modelName, 'at', new Date().toISOString());
|
||||
|
||||
// Find display name
|
||||
const model = modelStatus?.models.find((m) => m.model_name === modelName);
|
||||
const displayName = model?.display_name || modelName;
|
||||
|
||||
try {
|
||||
// IMPORTANT: Call the API FIRST before setting state
|
||||
// Setting state enables the SSE EventSource in useModelDownloadToast,
|
||||
// which can block/delay the download fetch due to HTTP/1.1 connection limits
|
||||
console.log('[Download] Calling download API for:', modelName);
|
||||
const result = await apiClient.triggerModelDownload(modelName);
|
||||
console.log('[Download] Download API responded:', result);
|
||||
|
||||
// NOW set state to enable SSE tracking (after download has started on backend)
|
||||
setDownloadingModel(modelName);
|
||||
// Find display name from model status
|
||||
const model = modelStatus?.models.find((m) => m.model_name === modelName);
|
||||
setDownloadingDisplayName(model?.display_name || modelName);
|
||||
return apiClient.triggerModelDownload(modelName);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Download completed - clear state and refetch status
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
setDownloadingDisplayName(displayName);
|
||||
|
||||
// Download initiated successfully - state will be cleared when SSE reports completion
|
||||
// or by the polling interval detecting the model is downloaded
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
} catch (error) {
|
||||
console.error('[Download] Download failed:', error);
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
toast({
|
||||
title: 'Download failed',
|
||||
description: error.message,
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (modelName: string) => apiClient.deleteModel(modelName),
|
||||
onSuccess: () => {
|
||||
mutationFn: async (modelName: string) => {
|
||||
console.log('[Delete] Deleting model:', modelName);
|
||||
const result = await apiClient.deleteModel(modelName);
|
||||
console.log('[Delete] Model deleted successfully:', modelName);
|
||||
return result;
|
||||
},
|
||||
onSuccess: async (_data, _modelName) => {
|
||||
console.log('[Delete] onSuccess - showing toast and invalidating queries');
|
||||
toast({
|
||||
title: 'Model deleted',
|
||||
description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`,
|
||||
});
|
||||
setDeleteDialogOpen(false);
|
||||
setModelToDelete(null);
|
||||
// Refetch status to update UI
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
// Invalidate AND explicitly refetch to ensure UI updates
|
||||
// Using refetchType: 'all' ensures we refetch even if the query is stale
|
||||
console.log('[Delete] Invalidating modelStatus query');
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['modelStatus'],
|
||||
refetchType: 'all',
|
||||
});
|
||||
// Also explicitly refetch to guarantee fresh data
|
||||
console.log('[Delete] Explicitly refetching modelStatus query');
|
||||
await queryClient.refetchQueries({ queryKey: ['modelStatus'] });
|
||||
console.log('[Delete] Query refetched');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.log('[Delete] onError:', error);
|
||||
toast({
|
||||
title: 'Delete failed',
|
||||
description: error.message,
|
||||
@@ -108,7 +155,10 @@ export function ModelManagement() {
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Icon
|
||||
icon="svg-spinners:ring-resize"
|
||||
className="h-6 w-6 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
) : modelStatus ? (
|
||||
<div className="space-y-4">
|
||||
@@ -124,7 +174,7 @@ export function ModelManagement() {
|
||||
<ModelItem
|
||||
key={model.model_name}
|
||||
model={model}
|
||||
onDownload={() => downloadMutation.mutate(model.model_name)}
|
||||
onDownload={() => handleDownload(model.model_name)}
|
||||
onDelete={() => {
|
||||
setModelToDelete({
|
||||
name: model.model_name,
|
||||
@@ -152,7 +202,7 @@ export function ModelManagement() {
|
||||
<ModelItem
|
||||
key={model.model_name}
|
||||
model={model}
|
||||
onDownload={() => downloadMutation.mutate(model.model_name)}
|
||||
onDownload={() => handleDownload(model.model_name)}
|
||||
onDelete={() => {
|
||||
setModelToDelete({
|
||||
name: model.model_name,
|
||||
@@ -167,22 +217,6 @@ export function ModelManagement() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicators */}
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
|
||||
Download Progress
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{modelStatus.models.map((model) => (
|
||||
<ModelProgress
|
||||
key={model.model_name}
|
||||
modelName={model.model_name}
|
||||
displayName={model.display_name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
@@ -216,7 +250,7 @@ export function ModelManagement() {
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
@@ -235,16 +269,20 @@ interface ModelItemProps {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
downloaded: boolean;
|
||||
downloading?: boolean; // From server - true if download in progress
|
||||
size_mb?: number;
|
||||
loaded: boolean;
|
||||
};
|
||||
onDownload: () => void;
|
||||
onDelete: () => void;
|
||||
isDownloading: boolean;
|
||||
isDownloading: boolean; // Local state - true if user just clicked download
|
||||
formatSize: (sizeMb?: number) => string;
|
||||
}
|
||||
|
||||
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
|
||||
// Use server's downloading state OR local state (for immediate feedback before server updates)
|
||||
const showDownloading = model.downloading || isDownloading;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
@@ -255,20 +293,21 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
|
||||
Loaded
|
||||
</Badge>
|
||||
)}
|
||||
{model.downloaded && !model.loaded && (
|
||||
{/* Only show Downloaded if actually downloaded AND not downloading */}
|
||||
{model.downloaded && !model.loaded && !showDownloading && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Downloaded
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{model.downloaded && model.size_mb && (
|
||||
{model.downloaded && model.size_mb && !showDownloading && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Size: {formatSize(model.size_mb)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{model.downloaded ? (
|
||||
{model.downloaded && !showDownloading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span>Ready</span>
|
||||
@@ -280,22 +319,18 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
|
||||
disabled={model.loaded}
|
||||
title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : showDownloading ? (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 mr-2 animate-spin" />
|
||||
Downloading...
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={onDownload} disabled={isDownloading} variant="outline">
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" onClick={onDownload} variant="outline">
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Loader2, XCircle } from 'lucide-react';
|
||||
import { CancelCircleIcon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
@@ -8,14 +10,27 @@ import { useServerStore } from '@/stores/serverStore';
|
||||
interface ModelProgressProps {
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
/** Only connect to SSE when actively downloading - prevents connection exhaustion */
|
||||
isDownloading?: boolean;
|
||||
}
|
||||
|
||||
export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
export function ModelProgress({
|
||||
modelName,
|
||||
displayName,
|
||||
isDownloading = false,
|
||||
}: ModelProgressProps) {
|
||||
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverUrl) return;
|
||||
// IMPORTANT: Only connect to SSE when this specific model is downloading
|
||||
// Opening SSE connections for all models exhausts HTTP/1.1 connection limits (6 per origin)
|
||||
// which causes other fetches (like the download trigger) to be queued/blocked
|
||||
if (!serverUrl || !isDownloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[ModelProgress] Connecting SSE for ${modelName}`);
|
||||
|
||||
// Subscribe to progress updates via Server-Sent Events
|
||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||
@@ -27,6 +42,7 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
|
||||
// Close connection if complete or error
|
||||
if (data.status === 'complete' || data.status === 'error') {
|
||||
console.log(`[ModelProgress] Download ${data.status} for ${modelName}, closing SSE`);
|
||||
eventSource.close();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -35,14 +51,15 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
console.error(`[ModelProgress] SSE error for ${modelName}:`, error);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
console.log(`[ModelProgress] Cleanup - closing SSE for ${modelName}`);
|
||||
eventSource.close();
|
||||
};
|
||||
}, [serverUrl, modelName]);
|
||||
}, [serverUrl, modelName, isDownloading]);
|
||||
|
||||
// Don't render if no progress or if complete/error and some time has passed
|
||||
if (
|
||||
@@ -63,10 +80,12 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
const getStatusIcon = () => {
|
||||
switch (progress.status) {
|
||||
case 'error':
|
||||
return <XCircle className="h-4 w-4 text-destructive" />;
|
||||
return (
|
||||
<HugeiconsIcon icon={CancelCircleIcon} size={16} className="h-4 w-4 text-destructive" />
|
||||
);
|
||||
case 'downloading':
|
||||
case 'extracting':
|
||||
return <Loader2 className="h-4 w-4 animate-spin" />;
|
||||
return <Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
import { Delete01Icon, Download01Icon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
|
||||
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
||||
const isWindows = () => navigator.platform.toLowerCase().includes('win');
|
||||
const getPlatformName = () => {
|
||||
if (isMacOS()) return 'macOS';
|
||||
if (isWindows()) return 'Windows';
|
||||
return 'Linux';
|
||||
};
|
||||
|
||||
type ProviderType =
|
||||
| 'auto'
|
||||
| 'apple-mlx'
|
||||
| 'bundled-pytorch'
|
||||
| 'pytorch-cpu'
|
||||
| 'pytorch-cuda'
|
||||
| 'remote'
|
||||
| 'openai';
|
||||
|
||||
export function ProviderSettings() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [downloadingProvider, setDownloadingProvider] = useState<string | null>(null);
|
||||
|
||||
const { data: providersData, isLoading } = useQuery({
|
||||
queryKey: ['providers'],
|
||||
queryFn: async () => {
|
||||
return await apiClient.listProviders();
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: activeProvider } = useQuery({
|
||||
queryKey: ['activeProvider'],
|
||||
queryFn: async () => {
|
||||
return await apiClient.getActiveProvider();
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
// Callbacks for download completion
|
||||
const handleDownloadComplete = useCallback(() => {
|
||||
setDownloadingProvider(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||
}, [queryClient]);
|
||||
|
||||
const handleDownloadError = useCallback(() => {
|
||||
setDownloadingProvider(null);
|
||||
}, []);
|
||||
|
||||
// Use progress toast hook for the downloading provider
|
||||
useModelDownloadToast({
|
||||
modelName: downloadingProvider || '',
|
||||
displayName: downloadingProvider || '',
|
||||
enabled: !!downloadingProvider,
|
||||
onComplete: handleDownloadComplete,
|
||||
onError: handleDownloadError,
|
||||
});
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [providerToDelete, setProviderToDelete] = useState<string | null>(null);
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: async (providerType: string) => {
|
||||
return await apiClient.downloadProvider(providerType);
|
||||
},
|
||||
onSuccess: (_, providerType) => {
|
||||
setDownloadingProvider(providerType);
|
||||
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Download failed',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: async (providerType: string) => {
|
||||
return await apiClient.startProvider(providerType);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activeProvider'] });
|
||||
toast({
|
||||
title: 'Provider started',
|
||||
description: 'The provider has been started successfully',
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to start provider',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (providerType: string) => {
|
||||
return await apiClient.deleteProvider(providerType);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||
toast({
|
||||
title: 'Provider deleted',
|
||||
description: 'The provider has been deleted successfully',
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to delete provider',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownload = async (providerType: string) => {
|
||||
downloadMutation.mutate(providerType);
|
||||
};
|
||||
|
||||
const handleStart = async (providerType: string) => {
|
||||
startMutation.mutate(providerType);
|
||||
};
|
||||
|
||||
const handleDelete = (providerType: string) => {
|
||||
setProviderToDelete(providerType);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (providerToDelete) {
|
||||
deleteMutation.mutate(providerToDelete);
|
||||
setDeleteDialogOpen(false);
|
||||
setProviderToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>TTS Provider</CardTitle>
|
||||
<CardDescription>Choose how Voicebox generates speech</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const installedProviders = providersData?.installed || [];
|
||||
|
||||
// Determine current active provider
|
||||
const currentProvider = activeProvider?.provider;
|
||||
console.log('currentProvider', currentProvider);
|
||||
const selectedProvider = currentProvider as ProviderType;
|
||||
|
||||
const isStarting = startMutation.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>TTS Provider</CardTitle>
|
||||
<CardDescription>Choose how Voicebox generates speech.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="relative">
|
||||
{isStarting && (
|
||||
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-5 w-5" />
|
||||
<span>Starting provider...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<RadioGroup
|
||||
value={selectedProvider}
|
||||
onValueChange={(value) => handleStart(value)}
|
||||
disabled={isStarting}
|
||||
>
|
||||
{/* PyTorch CUDA */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className={`flex items-center space-x-3 flex-1 ${isMacOS() || !installedProviders.includes('pytorch-cuda') ? 'opacity-50' : ''}`}>
|
||||
<RadioGroupItem value="pytorch-cuda" id="cuda" disabled={isMacOS() || isStarting || !installedProviders.includes('pytorch-cuda')} />
|
||||
<Label
|
||||
htmlFor="cuda"
|
||||
className={`flex-1 ${isMacOS() || isStarting || !installedProviders.includes('pytorch-cuda') ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="font-medium">PyTorch CUDA</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
NVIDIA GPU-accelerated provider
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isMacOS() && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">2.4GB</span>
|
||||
<Button size="sm" variant="secondary" disabled>
|
||||
Not Available on macOS
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isMacOS() && !installedProviders.includes('pytorch-cuda') && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">2.4GB</span>
|
||||
<Button
|
||||
onClick={() => handleDownload('pytorch-cuda')}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingProvider === 'pytorch-cuda' || isStarting}
|
||||
className="shrink-0"
|
||||
>
|
||||
{downloadingProvider === 'pytorch-cuda' ? (
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{installedProviders.includes('pytorch-cuda') && (
|
||||
<Button
|
||||
onClick={() => handleDelete('pytorch-cuda')}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isStarting}
|
||||
className="shrink-0"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Uninstall
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PyTorch CPU */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className={`flex items-center space-x-3 flex-1 ${!installedProviders.includes('pytorch-cpu') ? 'opacity-50' : ''}`}>
|
||||
<RadioGroupItem value="pytorch-cpu" id="cpu" disabled={isStarting || !installedProviders.includes('pytorch-cpu')} />
|
||||
<Label
|
||||
htmlFor="cpu"
|
||||
className={`flex-1 ${isStarting || !installedProviders.includes('pytorch-cpu') ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="font-medium">PyTorch CPU</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Works on any system, slower inference
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!installedProviders.includes('pytorch-cpu') && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">242MB</span>
|
||||
<Button
|
||||
onClick={() => handleDownload('pytorch-cpu')}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingProvider === 'pytorch-cpu' || isStarting}
|
||||
className="shrink-0"
|
||||
>
|
||||
{downloadingProvider === 'pytorch-cpu' ? (
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{installedProviders.includes('pytorch-cpu') && (
|
||||
<Button
|
||||
onClick={() => handleDelete('pytorch-cpu')}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isStarting}
|
||||
className="shrink-0"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Uninstall
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MLX bundled (macOS Apple Silicon only) */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className={`flex items-center space-x-3 flex-1 ${!isMacOS() ? 'opacity-50' : ''}`}>
|
||||
<RadioGroupItem value="apple-mlx" id="mlx" disabled={isStarting || !isMacOS()} />
|
||||
<Label
|
||||
htmlFor="mlx"
|
||||
className={`flex-1 ${isStarting || !isMacOS() ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="font-medium">Apple MLX</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isMacOS()
|
||||
? 'Bundled with this version, optimized for Apple Silicon'
|
||||
: 'Only available on Apple Silicon'}
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{!isMacOS() && (
|
||||
<Button size="sm" variant="secondary" disabled>
|
||||
Not Available on {getPlatformName()}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remote */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center space-x-3 flex-1 opacity-50">
|
||||
<RadioGroupItem value="remote" id="remote" disabled />
|
||||
<Label htmlFor="remote" className="flex-1 cursor-not-allowed">
|
||||
<div className="font-medium">Remote Server</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Connect to your own TTS server
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" disabled>
|
||||
Coming Soon
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* OpenAI */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center space-x-3 flex-1 opacity-50">
|
||||
<RadioGroupItem value="openai" id="openai" disabled />
|
||||
<Label htmlFor="openai" className="flex-1 cursor-not-allowed">
|
||||
<div className="font-medium">OpenAI API</div>
|
||||
<div className="text-sm text-muted-foreground">Use OpenAI's TTS API</div>
|
||||
</Label>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" disabled>
|
||||
Coming Soon
|
||||
</Button>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<p className="text-xs text-muted-foreground mt-5">
|
||||
Note: PyTorch and MLX use different versions of the same model. When switching between
|
||||
them, you will need to redownload the model.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Provider</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete {providerToDelete}? This will remove the provider
|
||||
binary from your system. You can download it again later if needed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-destructive text-destructive-foreground"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Loader2, XCircle } from 'lucide-react';
|
||||
import { CancelCircleIcon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
@@ -32,12 +34,12 @@ export function ServerStatus() {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">Checking connection...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
<HugeiconsIcon icon={CancelCircleIcon} size={16} className="h-4 w-4 text-destructive" />
|
||||
<span className="text-sm text-destructive">Connection failed: {error.message}</span>
|
||||
</div>
|
||||
) : health ? (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlertCircle, Download, RefreshCw } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { AlertCircleIcon, Download01Icon, Refresh01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -13,9 +14,10 @@ export function UpdateStatus() {
|
||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
platform.metadata.getVersion()
|
||||
platform.metadata
|
||||
.getVersion()
|
||||
.then(setCurrentVersion)
|
||||
.catch(() => setCurrentVersion('0.1.0'));
|
||||
.catch(() => setCurrentVersion('Unknown'));
|
||||
}, [platform]);
|
||||
|
||||
return (
|
||||
@@ -35,21 +37,21 @@ export function UpdateStatus() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
|
||||
<HugeiconsIcon icon={Refresh01Icon} size={16} className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
|
||||
Check for Updates
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{status.checking && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
<HugeiconsIcon icon={Refresh01Icon} size={16} className="h-4 w-4 animate-spin" />
|
||||
Checking for updates...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={AlertCircleIcon} size={16} className="h-4 w-4" />
|
||||
{status.error}
|
||||
</div>
|
||||
)}
|
||||
@@ -64,7 +66,7 @@ export function UpdateStatus() {
|
||||
<Badge>New</Badge>
|
||||
</div>
|
||||
<Button onClick={downloadAndInstall} className="w-full" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Download Update
|
||||
</Button>
|
||||
</div>
|
||||
@@ -74,7 +76,7 @@ export function UpdateStatus() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4" />
|
||||
Downloading update...
|
||||
</div>
|
||||
{status.downloadProgress !== undefined && (
|
||||
@@ -108,7 +110,7 @@ export function UpdateStatus() {
|
||||
your convenience.
|
||||
</div>
|
||||
<Button onClick={restartAndInstall} className="w-full" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
<HugeiconsIcon icon={Refresh01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Restart Now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||
import { DataFolders } from '@/components/ServerSettings/DataFolders';
|
||||
import { ProviderSettings } from '@/components/ServerSettings/ProviderSettings';
|
||||
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
||||
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
@@ -11,6 +13,8 @@ export function ServerTab() {
|
||||
<ConnectionForm />
|
||||
<ServerStatus />
|
||||
</div>
|
||||
<ProviderSettings />
|
||||
<DataFolders />
|
||||
{platform.metadata.isTauri && <UpdateStatus />}
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
Created by{' '}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import {
|
||||
Book01Icon,
|
||||
Mic01Icon,
|
||||
PackageIcon,
|
||||
ServerStack01Icon,
|
||||
SpeakerIcon,
|
||||
VolumeHighIcon,
|
||||
} from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||
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';
|
||||
@@ -10,12 +19,12 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
|
||||
{ id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' },
|
||||
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
|
||||
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
|
||||
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
|
||||
{ id: 'server', path: '/server', icon: Server, label: 'Server' },
|
||||
{ id: 'main', path: '/', icon: VolumeHighIcon, label: 'Generate' },
|
||||
{ id: 'stories', path: '/stories', icon: Book01Icon, label: 'Stories' },
|
||||
{ id: 'voices', path: '/voices', icon: Mic01Icon, label: 'Voices' },
|
||||
{ id: 'audio', path: '/audio', icon: SpeakerIcon, label: 'Audio' },
|
||||
{ id: 'models', path: '/models', icon: PackageIcon, label: 'Models' },
|
||||
{ id: 'server', path: '/server', icon: ServerStack01Icon, label: 'Server' },
|
||||
];
|
||||
|
||||
export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
@@ -42,9 +51,7 @@ 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: '/' }) : matchRoute({ to: tab.path });
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -58,7 +65,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
title={tab.label}
|
||||
aria-label={tab.label}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<HugeiconsIcon icon={Icon} size={20} className="h-5 w-5" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -75,7 +82,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
isPlayerVisible ? 'mb-[120px]' : 'mb-0',
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-6 w-6 text-accent animate-spin" />
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-6 w-6 text-accent animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { DragDropVerticalIcon, MoreHorizontalIcon, PlayIcon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ProfileAvatar } from '@/components/VoiceProfiles/ProfileAvatar';
|
||||
import type { StoryItemDetail } from '@/lib/api/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
interface StoryChatItemProps {
|
||||
item: StoryItemDetail;
|
||||
@@ -35,10 +35,6 @@ export function StoryChatItem({
|
||||
isDragging,
|
||||
}: StoryChatItemProps) {
|
||||
const seek = useStoryStore((state) => state.seek);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
|
||||
const avatarUrl = `${serverUrl}/profiles/${item.profile_id}/avatar`;
|
||||
|
||||
// Check if this item is currently playing based on timecode
|
||||
const itemStartMs = item.start_time_ms;
|
||||
@@ -74,27 +70,18 @@ export function StoryChatItem({
|
||||
className="shrink-0 cursor-grab active:cursor-grabbing touch-none text-muted-foreground hover:text-foreground transition-colors"
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
<HugeiconsIcon icon={DragDropVerticalIcon} size={20} className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Voice Avatar */}
|
||||
<div className="shrink-0">
|
||||
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
|
||||
{!avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${item.profile_name} avatar`}
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-all duration-200',
|
||||
!isCurrentlyPlaying && 'grayscale'
|
||||
)}
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Mic className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
profileId={item.profile_id}
|
||||
size="lg"
|
||||
grayscale={!isCurrentlyPlaying}
|
||||
alt={`${item.profile_name} avatar`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -119,16 +106,16 @@ export function StoryChatItem({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handlePlay}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={PlayIcon} size={16} className="mr-2 h-4 w-4" />
|
||||
Play from here
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onRemove} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Remove from Story
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Download, Plus } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Download01Icon, Add01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -271,7 +272,7 @@ export function StoryContent() {
|
||||
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Add
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -316,7 +317,7 @@ export function StoryContent() {
|
||||
onClick={handleExportAudio}
|
||||
disabled={exportAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Export Audio
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Plus, BookOpen, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Add01Icon, Book01Icon, MoreHorizontalIcon, PencilIcon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -29,7 +30,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useStories, useCreateStory, useUpdateStory, useDeleteStory } from '@/lib/hooks/useStories';
|
||||
import { useStories, useCreateStory, useUpdateStory, useDeleteStory, useStory } from '@/lib/hooks/useStories';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatDate } from '@/lib/utils/format';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
@@ -38,6 +39,8 @@ export function StoryList() {
|
||||
const { data: stories, isLoading } = useStories();
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||
const { data: currentStory } = useStory(selectedStoryId);
|
||||
const createStory = useCreateStory();
|
||||
const updateStory = useUpdateStory();
|
||||
const deleteStory = useDeleteStory();
|
||||
@@ -54,6 +57,16 @@ export function StoryList() {
|
||||
const [newStoryDescription, setNewStoryDescription] = useState('');
|
||||
const { toast } = useToast();
|
||||
|
||||
// Calculate bottom padding to account for FloatingGenerateBox and StoryTrackEditor
|
||||
const hasTrackEditor = currentStory && currentStory.items.length > 0;
|
||||
const bottomPadding = useMemo(() => {
|
||||
// FloatingGenerateBox height (~100px) + gap (24px)
|
||||
const generateBoxHeight = 124;
|
||||
// Track editor height when visible
|
||||
const editorHeight = hasTrackEditor ? trackEditorHeight + 24 : 0;
|
||||
return generateBoxHeight + editorHeight;
|
||||
}, [hasTrackEditor, trackEditorHeight]);
|
||||
|
||||
const handleCreateStory = () => {
|
||||
if (!newStoryName.trim()) {
|
||||
toast({
|
||||
@@ -177,16 +190,19 @@ export function StoryList() {
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h2 className="text-2xl font-bold">Stories</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
New Story
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Story List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-2"
|
||||
style={{ paddingBottom: `${bottomPadding}px` }}
|
||||
>
|
||||
{storyList.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<HugeiconsIcon icon={Book01Icon} size={48} className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">No stories yet</p>
|
||||
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||
</div>
|
||||
@@ -227,19 +243,19 @@ export function StoryList() {
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={PencilIcon} size={16} className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(story.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import {
|
||||
Copy,
|
||||
GripHorizontal,
|
||||
Minus,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
Scissors,
|
||||
Square,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
Copy01Icon,
|
||||
DragDropHorizontalIcon,
|
||||
RemoveIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
Add01Icon,
|
||||
Scissor01Icon,
|
||||
SquareIcon,
|
||||
Delete01Icon,
|
||||
} from '@hugeicons/core-free-icons';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -723,7 +724,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
onMouseDown={handleResizeStart}
|
||||
aria-label="Resize track editor"
|
||||
>
|
||||
<GripHorizontal className="h-3 w-3 text-muted-foreground/50 group-hover:text-muted-foreground" />
|
||||
<HugeiconsIcon icon={DragDropHorizontalIcon} size={12} className="h-3 w-3 text-muted-foreground/50 group-hover:text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{/* Toolbar */}
|
||||
@@ -737,7 +738,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
onClick={handlePlayPause}
|
||||
title="Play/Pause (Space)"
|
||||
>
|
||||
{isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{isCurrentlyPlaying ? <HugeiconsIcon icon={PauseIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={PlayIcon} size={16} className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -746,7 +747,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
onClick={handleStop}
|
||||
disabled={!isCurrentlyPlaying}
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
<HugeiconsIcon icon={SquareIcon} size={12} className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground tabular-nums ml-2">
|
||||
{formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
|
||||
@@ -763,7 +764,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
onClick={handleSplit}
|
||||
title="Split at playhead (S)"
|
||||
>
|
||||
<Scissors className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Scissor01Icon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -772,7 +773,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
onClick={handleDuplicate}
|
||||
title="Duplicate (Cmd/Ctrl+D)"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Copy01Icon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -781,7 +782,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
onClick={handleDelete}
|
||||
title="Delete (Delete/Backspace)"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -790,10 +791,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
<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}>
|
||||
<Minus className="h-3 w-3" />
|
||||
<HugeiconsIcon icon={RemoveIcon} size={12} className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
|
||||
<Plus className="h-3 w-3" />
|
||||
<HugeiconsIcon icon={Add01Icon} size={12} className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -837,7 +838,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
type="button"
|
||||
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
|
||||
style={{ width: `${timelineWidth}px` }}
|
||||
onClick={handleTimelineClick}
|
||||
onClick={(e) => handleTimelineClick(e as unknown as React.MouseEvent<HTMLDivElement>)}
|
||||
aria-label="Seek timeline"
|
||||
>
|
||||
{timeMarkers.map((ms) => (
|
||||
@@ -878,7 +879,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-0 cursor-pointer"
|
||||
onClick={handleTimelineClick}
|
||||
onClick={(e) => handleTimelineClick(e as unknown as React.MouseEvent<HTMLDivElement>)}
|
||||
aria-label="Seek timeline"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Mic, Pause, Play, Square } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Mic01Icon, PauseIcon, PlayIcon, SquareIcon } from '@hugeicons/core-free-icons';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { Visualizer } from 'react-sound-visualizer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -95,7 +96,7 @@ export function AudioSampleRecording({
|
||||
size="lg"
|
||||
className="relative z-10 flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-5 w-5" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={20} className="h-5 w-5" />
|
||||
Start Recording
|
||||
</Button>
|
||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||
@@ -122,7 +123,7 @@ export function AudioSampleRecording({
|
||||
onClick={onStop}
|
||||
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={SquareIcon} size={16} className="h-4 w-4" />
|
||||
Stop Recording
|
||||
</Button>
|
||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||
@@ -134,13 +135,13 @@ export function AudioSampleRecording({
|
||||
{file && !isRecording && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mic className="h-5 w-5 text-primary" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={20} className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Recording complete</span>
|
||||
</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}>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={PlayIcon} size={16} className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -149,7 +150,7 @@ export function AudioSampleRecording({
|
||||
disabled={isTranscribing}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Mic01Icon, DeskIcon, PauseIcon, PlayIcon, SquareIcon } from '@hugeicons/core-free-icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
@@ -35,7 +36,7 @@ export function AudioSampleSystem({
|
||||
{!isRecording && !file && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
<HugeiconsIcon icon={DeskIcon} size={20} className="h-5 w-5" />
|
||||
Start Capture
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
@@ -60,7 +61,7 @@ export function AudioSampleSystem({
|
||||
variant="destructive"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={SquareIcon} size={16} className="h-4 w-4" />
|
||||
Stop Capture
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
@@ -72,13 +73,13 @@ export function AudioSampleSystem({
|
||||
{file && !isRecording && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5 text-primary" />
|
||||
<HugeiconsIcon icon={DeskIcon} size={20} className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Capture complete</span>
|
||||
</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}>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={PlayIcon} size={16} className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -87,7 +88,7 @@ export function AudioSampleSystem({
|
||||
disabled={isTranscribing}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Mic, Pause, Play, Upload } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Mic01Icon, PauseIcon, PlayIcon, Upload01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
@@ -89,7 +90,7 @@ export function AudioSampleUpload({
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
<HugeiconsIcon icon={Upload01Icon} size={20} className="h-5 w-5" />
|
||||
Choose File
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
@@ -99,7 +100,7 @@ export function AudioSampleUpload({
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5 text-primary" />
|
||||
<HugeiconsIcon icon={Upload01Icon} size={20} className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">File uploaded</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
@@ -111,7 +112,7 @@ export function AudioSampleUpload({
|
||||
onClick={onPlayPause}
|
||||
disabled={isValidating}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={PlayIcon} size={16} className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -120,7 +121,7 @@ export function AudioSampleUpload({
|
||||
disabled={isTranscribing || isValidating || isDisabled}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Mic01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
interface ProfileAvatarProps {
|
||||
profileId: string;
|
||||
avatarPath?: string | null;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
grayscale?: boolean;
|
||||
className?: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-6 w-6',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-10 w-10',
|
||||
xl: 'h-24 w-24',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 20,
|
||||
xl: 40,
|
||||
};
|
||||
|
||||
const iconClassNames = {
|
||||
sm: 'h-3.5 w-3.5',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5',
|
||||
xl: 'h-10 w-10',
|
||||
};
|
||||
|
||||
export function ProfileAvatar({
|
||||
profileId,
|
||||
avatarPath,
|
||||
size = 'md',
|
||||
grayscale = false,
|
||||
className,
|
||||
alt = 'Profile avatar',
|
||||
}: ProfileAvatarProps) {
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
// If avatarPath is explicitly null or empty string, don't try to load avatar
|
||||
// Otherwise, always try to load (avatarPath might not be available in all contexts)
|
||||
const avatarUrl =
|
||||
avatarPath === null || avatarPath === '' ? null : `${serverUrl}/profiles/${profileId}/avatar`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
sizeClasses[size],
|
||||
'rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{avatarUrl && !avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-all duration-200',
|
||||
grayscale && 'grayscale',
|
||||
)}
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
) : (
|
||||
<HugeiconsIcon
|
||||
icon={Mic01Icon}
|
||||
size={iconSizes[size]}
|
||||
className={cn(iconClassNames[size], 'text-muted-foreground')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Download, Edit, Mic, Trash2 } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Download01Icon, Edit01Icon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -12,10 +13,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ProfileAvatar } from '@/components/VoiceProfiles/ProfileAvatar';
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
interface ProfileCardProps {
|
||||
@@ -24,19 +25,15 @@ interface ProfileCardProps {
|
||||
|
||||
export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
const deleteProfile = useDeleteProfile();
|
||||
const exportProfile = useExportProfile();
|
||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
const isSelected = selectedProfileId === profile.id;
|
||||
|
||||
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
|
||||
|
||||
const handleSelect = () => {
|
||||
setSelectedProfileId(isSelected ? null : profile.id);
|
||||
};
|
||||
@@ -72,21 +69,13 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
>
|
||||
<CardHeader className="p-3 pb-2">
|
||||
<CardTitle className="flex items-center gap-1.5 text-base font-medium">
|
||||
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{avatarUrl && !avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${profile.name} avatar`}
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-all duration-200',
|
||||
!isSelected && 'grayscale',
|
||||
)}
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Mic className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
profileId={profile.id}
|
||||
avatarPath={profile.avatar_path}
|
||||
size="sm"
|
||||
grayscale={!isSelected}
|
||||
alt={`${profile.name} avatar`}
|
||||
/>
|
||||
<span className="break-words">{profile.name}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -101,13 +90,13 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
</div>
|
||||
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
||||
<CircleButton
|
||||
icon={Download}
|
||||
icon={(props) => <HugeiconsIcon icon={Download01Icon} size={14} {...props} />}
|
||||
onClick={handleExport}
|
||||
disabled={exportProfile.isPending}
|
||||
aria-label="Export profile"
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
icon={(props) => <HugeiconsIcon icon={Edit01Icon} size={14} {...props} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit();
|
||||
@@ -115,7 +104,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
aria-label="Edit profile"
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Trash2}
|
||||
icon={(props) => <HugeiconsIcon icon={Delete01Icon} size={14} {...props} />}
|
||||
onClick={handleDeleteClick}
|
||||
disabled={deleteProfile.isPending}
|
||||
aria-label="Delete profile"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Edit2, Mic, Monitor, Upload, X } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Edit02Icon, Mic01Icon, DeskIcon, Upload01Icon, Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
@@ -635,7 +636,7 @@ export function ProfileForm() {
|
||||
setSampleMode('record');
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={12} className="h-3 w-3 mr-1" />
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
@@ -668,16 +669,16 @@ export function ProfileForm() {
|
||||
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
<HugeiconsIcon icon={Upload01Icon} size={16} className="h-4 w-4 shrink-0" />
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
</TabsTrigger>
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
<HugeiconsIcon icon={DeskIcon} size={16} className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
</TabsTrigger>
|
||||
)}
|
||||
@@ -798,7 +799,7 @@ export function ProfileForm() {
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Mic className="h-10 w-10 text-muted-foreground" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={40} className="h-10 w-10 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
@@ -806,7 +807,7 @@ export function ProfileForm() {
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
className="absolute inset-0 rounded-full bg-accent/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<Edit2 className="h-6 w-6 text-accent-foreground" />
|
||||
<HugeiconsIcon icon={Edit02Icon} size={24} className="h-6 w-6 text-accent-foreground" />
|
||||
</button>
|
||||
{(avatarPreview || editingProfile?.avatar_path) && (
|
||||
<button
|
||||
@@ -815,7 +816,7 @@ export function ProfileForm() {
|
||||
disabled={deleteAvatar.isPending}
|
||||
className="absolute bottom-0 right-0 h-6 w-6 rounded-full bg-background/60 backdrop-blur-sm text-muted-foreground flex items-center justify-center hover:bg-background/80 hover:text-foreground transition-colors shadow-sm border border-border/50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={14} className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Mic, Sparkles } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Mic01Icon, SparklesIcon } from '@hugeicons/core-free-icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
@@ -30,12 +31,12 @@ export function ProfileList() {
|
||||
{allProfiles.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={48} className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No voice profiles yet. Create your first profile to get started.
|
||||
</p>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={SparklesIcon} size={16} className="mr-2 h-4 w-4" />
|
||||
Create Voice
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { CheckmarkCircle01Icon, Edit01Icon, PauseIcon, PlayIcon, Add01Icon, Delete01Icon, VolumeHighIcon, Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CircleButton } from '@/components/ui/circle-button';
|
||||
@@ -103,7 +104,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
||||
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={14} className="h-3.5 w-3.5" /> : <HugeiconsIcon icon={PlayIcon} size={14} className="h-3.5 w-3.5 ml-0.5" />}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
@@ -129,7 +130,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
onClick={handleStop}
|
||||
title="Stop"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={14} className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,7 +210,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
<div className="space-y-4 pt-4">
|
||||
{samples && samples.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
||||
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<HugeiconsIcon icon={VolumeHighIcon} size={32} className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No samples yet</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Add your first audio sample to get started
|
||||
@@ -232,7 +233,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
/* Edit Mode */
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||
<Edit className="h-3 w-3" />
|
||||
<HugeiconsIcon icon={Edit01Icon} size={12} className="h-3 w-3" />
|
||||
<span>Editing transcription</span>
|
||||
</div>
|
||||
<Textarea
|
||||
@@ -250,7 +251,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
onClick={handleCancelEdit}
|
||||
disabled={updateSample.isPending}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={16} className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@@ -259,7 +260,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
onClick={() => handleSaveEdit(sample.id)}
|
||||
disabled={updateSample.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
<HugeiconsIcon icon={CheckmarkCircle01Icon} size={16} className="h-4 w-4 mr-1" />
|
||||
{updateSample.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -278,12 +279,12 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
{/* Action Buttons */}
|
||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
icon={(props) => <HugeiconsIcon icon={Edit01Icon} size={14} {...props} />}
|
||||
title="Edit transcription"
|
||||
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Trash2}
|
||||
icon={(props) => <HugeiconsIcon icon={Delete01Icon} size={14} {...props} />}
|
||||
title="Delete sample"
|
||||
onClick={() => handleDeleteClick(sample.id)}
|
||||
disabled={deleteSample.isPending}
|
||||
@@ -312,7 +313,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
className="w-full"
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Add Sample
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Mic, Monitor, Upload } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Mic01Icon, DeskIcon, Upload01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
@@ -236,16 +237,16 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
<HugeiconsIcon icon={Upload01Icon} size={16} className="h-4 w-4 shrink-0" />
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
</TabsTrigger>
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
<HugeiconsIcon icon={DeskIcon} size={16} className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Edit, MoreHorizontal, Plus, Trash2, Mic } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Edit01Icon, MoreHorizontalIcon, Add01Icon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
|
||||
import { ProfileAvatar } from '@/components/VoiceProfiles/ProfileAvatar';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
@@ -112,7 +114,7 @@ export function VoicesTab() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Voices</h1>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
New Voice
|
||||
</Button>
|
||||
</div>
|
||||
@@ -184,9 +186,12 @@ function VoiceRow({
|
||||
<TableRow className="cursor-pointer" onClick={onEdit}>
|
||||
<TableCell>
|
||||
<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>
|
||||
<ProfileAvatar
|
||||
profileId={profile.id}
|
||||
avatarPath={profile.avatar_path}
|
||||
size="md"
|
||||
alt={`${profile.name} avatar`}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{profile.name}</div>
|
||||
{profile.description && (
|
||||
@@ -214,16 +219,16 @@ function VoiceRow({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
<HugeiconsIcon icon={Edit01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { CheckmarkCircle01Icon } from '@hugeicons/core-free-icons';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface CheckboxProps {
|
||||
@@ -34,7 +35,7 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{checked && <Check className="h-3 w-3 text-accent-foreground" />}
|
||||
{checked && <HugeiconsIcon icon={CheckmarkCircle01Icon} size={12} className="h-3 w-3 text-accent-foreground" />}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
@@ -42,7 +43,7 @@ const DialogContent = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={16} className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { MoreHorizontalIcon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
@@ -26,7 +27,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<MoreHorizontal className="ml-auto h-4 w-4" />
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
@@ -73,7 +74,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
@@ -97,7 +98,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -119,7 +120,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<MoreHorizontal className="h-2 w-2 fill-current" />
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} size={8} className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -154,7 +155,9 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />;
|
||||
return (
|
||||
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronDown, Check } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { ArrowDown01Icon, CheckmarkCircle01Icon } from '@hugeicons/core-free-icons';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -36,7 +37,7 @@ const MultiSelectCheckboxItem = React.forwardRef<
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={CheckmarkCircle01Icon} size={16} className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -78,7 +79,7 @@ export function MultiSelect({
|
||||
)}
|
||||
>
|
||||
<span className="line-clamp-1">{displayText}</span>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} size={16} className="h-4 w-4 opacity-50" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { CircleIcon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return <RadioGroupPrimitive.Root className={cn('grid gap-2', className)} {...props} ref={ref} />;
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'aspect-square h-4 w-4 rounded-full border border-accent text-accent ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<HugeiconsIcon
|
||||
icon={CircleIcon}
|
||||
size={10}
|
||||
className="h-2.5 w-2.5 fill-current text-current"
|
||||
/>
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { CheckmarkCircle01Icon, ArrowDown01Icon, ArrowUp01Icon } from '@hugeicons/core-free-icons';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
@@ -23,7 +24,7 @@ const SelectTrigger = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} size={16} className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
@@ -38,7 +39,7 @@ const SelectScrollUpButton = React.forwardRef<
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={ArrowUp01Icon} size={16} className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
@@ -52,7 +53,7 @@ const SelectScrollDownButton = React.forwardRef<
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} size={16} className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
@@ -115,7 +116,7 @@ const SelectItem = React.forwardRef<
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={CheckmarkCircle01Icon} size={16} className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
@@ -79,7 +80,7 @@ const ToastClose = React.forwardRef<
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={16} className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import type { UpdateStatus } from '@/platform/types';
|
||||
|
||||
// Re-export UpdateStatus for backwards compatibility
|
||||
export type { UpdateStatus };
|
||||
|
||||
export function useAutoUpdater(checkOnMount = false) {
|
||||
const platform = usePlatform();
|
||||
const [status, setStatus] = useState<UpdateStatus>(
|
||||
platform.updater.getStatus(),
|
||||
);
|
||||
|
||||
// Subscribe to updater status changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = platform.updater.subscribe((newStatus) => {
|
||||
setStatus(newStatus);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [platform]);
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
await platform.updater.checkForUpdates();
|
||||
}, [platform]);
|
||||
|
||||
const downloadAndInstall = useCallback(async () => {
|
||||
await platform.updater.downloadAndInstall();
|
||||
}, [platform]);
|
||||
|
||||
const restartAndInstall = useCallback(async () => {
|
||||
await platform.updater.restartAndInstall();
|
||||
}, [platform]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkOnMount && platform.metadata.isTauri) {
|
||||
checkForUpdates();
|
||||
}
|
||||
}, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
|
||||
|
||||
return {
|
||||
status,
|
||||
checkForUpdates,
|
||||
downloadAndInstall,
|
||||
restartAndInstall,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Download01Icon, Refresh01Icon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ToastAction } from '@/components/ui/toast';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import type { UpdateStatus } from '@/platform/types';
|
||||
|
||||
// Re-export UpdateStatus for backwards compatibility
|
||||
export type { UpdateStatus };
|
||||
|
||||
interface UseAutoUpdaterOptions {
|
||||
checkOnMount?: boolean;
|
||||
showToast?: boolean;
|
||||
}
|
||||
|
||||
export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) {
|
||||
// Support both old boolean API and new options object
|
||||
const { checkOnMount, showToast } =
|
||||
typeof options === 'boolean'
|
||||
? { checkOnMount: options, showToast: false }
|
||||
: { checkOnMount: options.checkOnMount ?? false, showToast: options.showToast ?? false };
|
||||
|
||||
const platform = usePlatform();
|
||||
const { toast } = useToast();
|
||||
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||
const hasCheckedRef = useRef(false);
|
||||
const toastIdRef = useRef<string | null>(null);
|
||||
const toastUpdateRef = useRef<
|
||||
| ((props: {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
duration?: number;
|
||||
variant?: 'default' | 'destructive';
|
||||
open?: boolean;
|
||||
action?: React.ReactElement<typeof ToastAction>;
|
||||
}) => void)
|
||||
| null
|
||||
>(null);
|
||||
|
||||
// Subscribe to updater status changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = platform.updater.subscribe((newStatus) => {
|
||||
setStatus(newStatus);
|
||||
});
|
||||
return unsubscribe;
|
||||
// Empty dependency array - platform is stable from context
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.subscribe]);
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
await platform.updater.checkForUpdates();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.checkForUpdates]);
|
||||
|
||||
const downloadAndInstall = useCallback(async () => {
|
||||
await platform.updater.downloadAndInstall();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.downloadAndInstall]);
|
||||
|
||||
const restartAndInstall = useCallback(async () => {
|
||||
await platform.updater.restartAndInstall();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.restartAndInstall]);
|
||||
|
||||
// Check for updates on mount
|
||||
useEffect(() => {
|
||||
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||
hasCheckedRef.current = true;
|
||||
checkForUpdates().catch((error) => {
|
||||
console.error('Auto update check failed:', error);
|
||||
});
|
||||
}
|
||||
// Empty dependency array - only run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauri, checkOnMount, checkForUpdates]);
|
||||
|
||||
// Show toast when update is available
|
||||
useEffect(() => {
|
||||
if (
|
||||
!showToast ||
|
||||
!status.available ||
|
||||
status.downloading ||
|
||||
status.readyToInstall ||
|
||||
toastIdRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleUpdateNow = async () => {
|
||||
await downloadAndInstall();
|
||||
};
|
||||
|
||||
const toastResult = toast({
|
||||
title: 'Update Available',
|
||||
description: `Version ${status.version} is ready to download.`,
|
||||
duration: Infinity,
|
||||
action: (
|
||||
<ToastAction altText="Update now" onClick={handleUpdateNow}>
|
||||
Update Now
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
|
||||
toastIdRef.current = toastResult.id;
|
||||
// Type assertion needed because update function has broader type than our ref
|
||||
toastUpdateRef.current = toastResult.update as typeof toastUpdateRef.current;
|
||||
}, [
|
||||
showToast,
|
||||
status.available,
|
||||
status.downloading,
|
||||
status.readyToInstall,
|
||||
status.version,
|
||||
downloadAndInstall,
|
||||
toast,
|
||||
]);
|
||||
|
||||
// Update toast when downloading
|
||||
useEffect(() => {
|
||||
if (!showToast || !status.downloading || !toastIdRef.current || !toastUpdateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progressPercent = status.downloadProgress || 0;
|
||||
const progressText =
|
||||
status.downloadedBytes !== undefined &&
|
||||
status.totalBytes !== undefined &&
|
||||
status.totalBytes > 0
|
||||
? `${(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB / ${(status.totalBytes / 1024 / 1024).toFixed(1)} MB`
|
||||
: '';
|
||||
|
||||
toastUpdateRef.current({
|
||||
title: (
|
||||
<div className="flex items-center gap-2">
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 animate-pulse" />
|
||||
<span>Downloading Update</span>
|
||||
</div>
|
||||
),
|
||||
description: (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Version {status.version}</div>
|
||||
{progressPercent > 0 && (
|
||||
<>
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
{progressText && <div className="text-xs text-muted-foreground">{progressText}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
duration: Infinity,
|
||||
});
|
||||
}, [
|
||||
showToast,
|
||||
status.downloading,
|
||||
status.downloadProgress,
|
||||
status.downloadedBytes,
|
||||
status.totalBytes,
|
||||
status.version,
|
||||
]);
|
||||
|
||||
// Update toast when ready to install
|
||||
useEffect(() => {
|
||||
if (!showToast || !status.readyToInstall || !toastIdRef.current || !toastUpdateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleRestartNow = async () => {
|
||||
await restartAndInstall();
|
||||
};
|
||||
|
||||
toastUpdateRef.current({
|
||||
title: 'Update Ready',
|
||||
description: `Version ${status.version} has been downloaded and is ready to install.`,
|
||||
duration: Infinity,
|
||||
action: (
|
||||
<ToastAction altText="Restart now" onClick={handleRestartNow}>
|
||||
<HugeiconsIcon icon={Refresh01Icon} size={12} className="h-3 w-3 mr-1" />
|
||||
Restart Now
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
}, [showToast, status.readyToInstall, status.version, restartAndInstall]);
|
||||
|
||||
// Handle errors in toast
|
||||
useEffect(() => {
|
||||
if (!showToast || !status.error || !toastIdRef.current || !toastUpdateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
toastUpdateRef.current({
|
||||
title: 'Update Failed',
|
||||
description: status.error,
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
toastIdRef.current = null;
|
||||
toastUpdateRef.current = null;
|
||||
}, 5000);
|
||||
}, [showToast, status.error]);
|
||||
|
||||
return {
|
||||
status,
|
||||
checkForUpdates,
|
||||
downloadAndInstall,
|
||||
restartAndInstall,
|
||||
};
|
||||
}
|
||||
+127
-31
@@ -1,29 +1,30 @@
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import type { LanguageCode } from '@/lib/constants/languages';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import type {
|
||||
VoiceProfileCreate,
|
||||
VoiceProfileResponse,
|
||||
ProfileSampleResponse,
|
||||
ActiveTasksResponse,
|
||||
FolderPathsResponse,
|
||||
GenerationRequest,
|
||||
GenerationResponse,
|
||||
HistoryQuery,
|
||||
HistoryListResponse,
|
||||
HistoryResponse,
|
||||
TranscriptionResponse,
|
||||
HealthResponse,
|
||||
ModelStatusListResponse,
|
||||
HistoryListResponse,
|
||||
HistoryQuery,
|
||||
HistoryResponse,
|
||||
ModelDownloadRequest,
|
||||
ActiveTasksResponse,
|
||||
ModelStatusListResponse,
|
||||
ProfileSampleResponse,
|
||||
StoryCreate,
|
||||
StoryResponse,
|
||||
StoryDetailResponse,
|
||||
StoryItemBatchUpdate,
|
||||
StoryItemCreate,
|
||||
StoryItemDetail,
|
||||
StoryItemBatchUpdate,
|
||||
StoryItemReorder,
|
||||
StoryItemMove,
|
||||
StoryItemTrim,
|
||||
StoryItemReorder,
|
||||
StoryItemSplit,
|
||||
StoryItemTrim,
|
||||
StoryResponse,
|
||||
TranscriptionResponse,
|
||||
VoiceProfileCreate,
|
||||
VoiceProfileResponse,
|
||||
} from './types';
|
||||
|
||||
class ApiClient {
|
||||
@@ -57,6 +58,11 @@ class ApiClient {
|
||||
return this.request<HealthResponse>('/health');
|
||||
}
|
||||
|
||||
// System
|
||||
async getSystemFolders(): Promise<FolderPathsResponse> {
|
||||
return this.request<FolderPathsResponse>('/system/folders');
|
||||
}
|
||||
|
||||
// Profiles
|
||||
async createProfile(data: VoiceProfileCreate): Promise<VoiceProfileResponse> {
|
||||
return this.request<VoiceProfileResponse>('/profiles', {
|
||||
@@ -199,6 +205,77 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Providers
|
||||
async listProviders(): Promise<{
|
||||
providers: Array<{
|
||||
type: string;
|
||||
name: string;
|
||||
installed: boolean;
|
||||
size_mb: number | null;
|
||||
}>;
|
||||
installed: string[];
|
||||
}> {
|
||||
return this.request('/providers');
|
||||
}
|
||||
|
||||
async getActiveProvider(): Promise<{
|
||||
provider: string;
|
||||
health: {
|
||||
status: string;
|
||||
provider: string;
|
||||
version: string | null;
|
||||
model: string | null;
|
||||
device: string | null;
|
||||
};
|
||||
status: {
|
||||
model_loaded: boolean;
|
||||
model_size: string | null;
|
||||
available_sizes: string[];
|
||||
gpu_available: boolean | null;
|
||||
vram_used_mb: number | null;
|
||||
};
|
||||
}> {
|
||||
return this.request('/providers/active');
|
||||
}
|
||||
|
||||
async startProvider(providerType: string): Promise<{
|
||||
message: string;
|
||||
provider: {
|
||||
status: string;
|
||||
provider: string;
|
||||
version: string | null;
|
||||
model: string | null;
|
||||
device: string | null;
|
||||
};
|
||||
}> {
|
||||
return this.request('/providers/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider_type: providerType }),
|
||||
});
|
||||
}
|
||||
|
||||
async stopProvider(): Promise<{ message: string }> {
|
||||
return this.request('/providers/stop', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async downloadProvider(providerType: string): Promise<{
|
||||
message: string;
|
||||
provider_type: string;
|
||||
}> {
|
||||
return this.request('/providers/download', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider_type: providerType }),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProvider(providerType: string): Promise<{ message: string }> {
|
||||
return this.request(`/providers/${providerType}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// History
|
||||
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
@@ -251,7 +328,15 @@ class ApiClient {
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async importGeneration(file: File): Promise<{ id: string; profile_id: string; profile_name: string; text: string; message: string }> {
|
||||
async importGeneration(
|
||||
file: File,
|
||||
): Promise<{
|
||||
id: string;
|
||||
profile_id: string;
|
||||
profile_name: string;
|
||||
text: string;
|
||||
message: string;
|
||||
}> {
|
||||
const url = `${this.getBaseUrl()}/history/import`;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
@@ -310,10 +395,18 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
|
||||
return this.request<{ message: string }>('/models/download', {
|
||||
console.log(
|
||||
'[API] triggerModelDownload called for:',
|
||||
modelName,
|
||||
'at',
|
||||
new Date().toISOString(),
|
||||
);
|
||||
const result = await this.request<{ message: string }>('/models/download', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
||||
});
|
||||
console.log('[API] triggerModelDownload response:', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteModel(modelName: string): Promise<{ message: string }> {
|
||||
@@ -340,10 +433,7 @@ class ApiClient {
|
||||
return this.request('/channels');
|
||||
}
|
||||
|
||||
async createChannel(data: {
|
||||
name: string;
|
||||
device_ids: string[];
|
||||
}): Promise<{
|
||||
async createChannel(data: { name: string; device_ids: string[] }): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
@@ -385,10 +475,7 @@ class ApiClient {
|
||||
return this.request(`/channels/${channelId}/voices`);
|
||||
}
|
||||
|
||||
async setChannelVoices(
|
||||
channelId: string,
|
||||
profileIds: string[],
|
||||
): Promise<{ message: string }> {
|
||||
async setChannelVoices(channelId: string, profileIds: string[]): Promise<{ message: string }> {
|
||||
return this.request(`/channels/${channelId}/voices`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ profile_ids: profileIds }),
|
||||
@@ -399,10 +486,7 @@ 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 }),
|
||||
@@ -465,21 +549,33 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async moveStoryItem(storyId: string, itemId: string, data: StoryItemMove): Promise<StoryItemDetail> {
|
||||
async moveStoryItem(
|
||||
storyId: string,
|
||||
itemId: string,
|
||||
data: StoryItemMove,
|
||||
): Promise<StoryItemDetail> {
|
||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async trimStoryItem(storyId: string, itemId: string, data: StoryItemTrim): Promise<StoryItemDetail> {
|
||||
async trimStoryItem(
|
||||
storyId: string,
|
||||
itemId: string,
|
||||
data: StoryItemTrim,
|
||||
): Promise<StoryItemDetail> {
|
||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/trim`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async splitStoryItem(storyId: string, itemId: string, data: StoryItemSplit): Promise<StoryItemDetail[]> {
|
||||
async splitStoryItem(
|
||||
storyId: string,
|
||||
itemId: string,
|
||||
data: StoryItemSplit,
|
||||
): Promise<StoryItemDetail[]> {
|
||||
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/${itemId}/split`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ModelStatus = {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
downloaded: boolean;
|
||||
downloading?: boolean; // True if download is in progress
|
||||
size_mb?: number | null;
|
||||
loaded?: boolean;
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface GenerationRequest {
|
||||
language: LanguageCode;
|
||||
seed?: number;
|
||||
model_size?: '1.7B' | '0.6B';
|
||||
instruct?: string;
|
||||
}
|
||||
|
||||
export interface GenerationResponse {
|
||||
@@ -96,6 +97,7 @@ export interface ModelStatus {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
downloaded: boolean;
|
||||
downloading: boolean; // True if download is in progress
|
||||
size_mb?: number;
|
||||
loaded: boolean;
|
||||
}
|
||||
@@ -126,6 +128,12 @@ export interface ActiveTasksResponse {
|
||||
generations: ActiveGenerationTask[];
|
||||
}
|
||||
|
||||
export interface FolderPathsResponse {
|
||||
data_dir: string;
|
||||
models_dir: string;
|
||||
providers_dir: string;
|
||||
}
|
||||
|
||||
export interface StoryCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
|
||||
@@ -91,11 +91,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||
instruct: data.instruct || undefined,
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { CancelCircleIcon, CheckmarkCircle02Icon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import type { ModelProgress } from '@/lib/api/types';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
interface UseModelDownloadToastOptions {
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
enabled?: boolean;
|
||||
onComplete?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,47 +23,64 @@ export function useModelDownloadToast({
|
||||
modelName,
|
||||
displayName,
|
||||
enabled = false,
|
||||
onComplete,
|
||||
onError,
|
||||
}: UseModelDownloadToastOptions) {
|
||||
const { toast } = useToast();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const toastIdRef = useRef<string | null>(null);
|
||||
const toastUpdateRef = useRef<
|
||||
((props: {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
duration?: number;
|
||||
variant?: 'default' | 'destructive';
|
||||
open?: boolean;
|
||||
}) => void) | null
|
||||
>(null);
|
||||
// biome-ignore lint: Using any for toast update ref to handle complex toast types
|
||||
const toastUpdateRef = useRef<any>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
const formatBytes = useCallback((bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[useModelDownloadToast] useEffect triggered', {
|
||||
enabled,
|
||||
serverUrl,
|
||||
modelName,
|
||||
displayName,
|
||||
});
|
||||
|
||||
if (!enabled || !serverUrl || !modelName) {
|
||||
console.log('[useModelDownloadToast] Not enabled, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[useModelDownloadToast] Creating toast and EventSource for:', modelName);
|
||||
|
||||
// Create initial toast
|
||||
const toastResult = toast({
|
||||
title: displayName,
|
||||
description: 'Starting download...',
|
||||
description: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||
<span>Connecting to download...</span>
|
||||
</div>
|
||||
),
|
||||
duration: Infinity, // Don't auto-dismiss, we'll handle it manually
|
||||
});
|
||||
toastIdRef.current = toastResult.id;
|
||||
toastUpdateRef.current = toastResult.update;
|
||||
|
||||
// Subscribe to progress updates via Server-Sent Events
|
||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||
const eventSourceUrl = `${serverUrl}/models/progress/${modelName}`;
|
||||
console.log('[useModelDownloadToast] Creating EventSource to:', eventSourceUrl);
|
||||
const eventSource = new EventSource(eventSourceUrl);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('[useModelDownloadToast] EventSource connection opened for:', modelName);
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
console.log('[useModelDownloadToast] Received SSE message:', event.data);
|
||||
try {
|
||||
const progress = JSON.parse(event.data) as ModelProgress;
|
||||
|
||||
@@ -77,19 +98,35 @@ export function useModelDownloadToast({
|
||||
|
||||
switch (progress.status) {
|
||||
case 'complete':
|
||||
statusIcon = <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
statusIcon = (
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle02Icon}
|
||||
size={16}
|
||||
className="h-4 w-4 text-green-500"
|
||||
/>
|
||||
);
|
||||
statusText = 'Download complete';
|
||||
break;
|
||||
case 'error':
|
||||
statusIcon = <XCircle className="h-4 w-4 text-destructive" />;
|
||||
statusIcon = (
|
||||
<HugeiconsIcon
|
||||
icon={CancelCircleIcon}
|
||||
size={16}
|
||||
className="h-4 w-4 text-destructive"
|
||||
/>
|
||||
);
|
||||
statusText = `Error: ${progress.error || 'Unknown error'}`;
|
||||
break;
|
||||
case 'downloading':
|
||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||
statusText = progress.filename ? `Downloading ${progress.filename}...` : 'Downloading...';
|
||||
statusIcon = (
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||
);
|
||||
statusText = progress.filename || 'Downloading...';
|
||||
break;
|
||||
case 'extracting':
|
||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||
statusIcon = (
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||
);
|
||||
statusText = 'Extracting...';
|
||||
break;
|
||||
}
|
||||
@@ -117,21 +154,44 @@ export function useModelDownloadToast({
|
||||
});
|
||||
|
||||
// Close connection and dismiss toast on completion or error
|
||||
if (progress.status === 'complete' || progress.status === 'error') {
|
||||
// Also treat progress >= 100% as complete
|
||||
const isComplete = progress.status === 'complete' || progress.progress >= 100;
|
||||
const isError = progress.status === 'error';
|
||||
|
||||
if (isComplete || isError) {
|
||||
console.log('[useModelDownloadToast] Download finished:', {
|
||||
isComplete,
|
||||
isError,
|
||||
progress: progress.progress,
|
||||
});
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
// Auto-dismiss on completion after delay
|
||||
if (progress.status === 'complete') {
|
||||
setTimeout(() => {
|
||||
if (toastIdRef.current && toastUpdateRef.current) {
|
||||
toastUpdateRef.current({
|
||||
open: false,
|
||||
});
|
||||
toastIdRef.current = null;
|
||||
toastUpdateRef.current = null;
|
||||
}
|
||||
}, 5000);
|
||||
// Update toast to show completion state before callbacks
|
||||
if (isComplete && toastUpdateRef.current) {
|
||||
toastUpdateRef.current({
|
||||
title: (
|
||||
<div className="flex items-center gap-2">
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle02Icon}
|
||||
size={16}
|
||||
className="h-4 w-4 text-green-500"
|
||||
/>
|
||||
<span>{displayName}</span>
|
||||
</div>
|
||||
),
|
||||
description: 'Download complete',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
// Call callbacks
|
||||
if (isComplete && onComplete) {
|
||||
console.log('[useModelDownloadToast] Download complete, calling onComplete callback');
|
||||
onComplete();
|
||||
} else if (isError && onError) {
|
||||
console.log('[useModelDownloadToast] Download error, calling onError callback');
|
||||
onError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +201,8 @@ export function useModelDownloadToast({
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
console.error('[useModelDownloadToast] SSE error for:', modelName, error);
|
||||
console.log('[useModelDownloadToast] EventSource readyState:', eventSource.readyState);
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
@@ -162,15 +223,16 @@ export function useModelDownloadToast({
|
||||
|
||||
// Cleanup on unmount or when disabled
|
||||
return () => {
|
||||
console.log('[useModelDownloadToast] Cleanup - closing EventSource for:', modelName);
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
// Note: We don't dismiss the toast here as it might still be showing completion state
|
||||
};
|
||||
}, [enabled, serverUrl, modelName, displayName, toast]);
|
||||
}, [enabled, serverUrl, modelName, displayName, toast, formatBytes, onComplete, onError]);
|
||||
|
||||
return {
|
||||
isTracking: enabled && eventSourceRef.current !== null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
export function useSystemFolders() {
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['system', 'folders', serverUrl],
|
||||
queryFn: () => apiClient.getSystemFolders(),
|
||||
staleTime: 60000, // Cache for 1 minute - folder paths don't change often
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,13 @@ export interface FileFilter {
|
||||
|
||||
export interface PlatformFilesystem {
|
||||
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
|
||||
/**
|
||||
* Open a folder in the native file explorer.
|
||||
* On web, this is a no-op since browsers cannot open folders.
|
||||
* @param path - The absolute path to the folder to open
|
||||
* @returns true if the folder was opened, false if not supported
|
||||
*/
|
||||
openFolder(path: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface UpdateStatus {
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Backend package
|
||||
|
||||
__version__ = "0.1.11"
|
||||
__version__ = "0.1.13"
|
||||
|
||||
@@ -118,22 +118,37 @@ _stt_backend: Optional[STTBackend] = None
|
||||
def get_tts_backend() -> TTSBackend:
|
||||
"""
|
||||
Get or create TTS backend instance based on platform.
|
||||
|
||||
|
||||
Returns:
|
||||
TTS backend instance (MLX or PyTorch)
|
||||
|
||||
Raises:
|
||||
ImportError: If required dependencies (mlx or torch) are not available
|
||||
"""
|
||||
global _tts_backend
|
||||
|
||||
|
||||
if _tts_backend is None:
|
||||
backend_type = get_backend_type()
|
||||
|
||||
|
||||
if backend_type == "mlx":
|
||||
from .mlx_backend import MLXTTSBackend
|
||||
_tts_backend = MLXTTSBackend()
|
||||
try:
|
||||
from .mlx_backend import MLXTTSBackend
|
||||
_tts_backend = MLXTTSBackend()
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
f"MLX backend dependencies not available. "
|
||||
f"Please install mlx and mlx_audio or download a provider. Error: {e}"
|
||||
)
|
||||
else:
|
||||
from .pytorch_backend import PyTorchTTSBackend
|
||||
_tts_backend = PyTorchTTSBackend()
|
||||
|
||||
try:
|
||||
from .pytorch_backend import PyTorchTTSBackend
|
||||
_tts_backend = PyTorchTTSBackend()
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
f"PyTorch backend dependencies not available. "
|
||||
f"Please download a TTS provider (pytorch-cpu or pytorch-cuda) from the Downloads page. Error: {e}"
|
||||
)
|
||||
|
||||
return _tts_backend
|
||||
|
||||
|
||||
|
||||
+150
-48
@@ -52,6 +52,47 @@ class MLXTTSBackend:
|
||||
|
||||
return hf_model_id
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
"""
|
||||
Check if the model is already cached locally AND fully downloaded.
|
||||
|
||||
Args:
|
||||
model_size: Model size to check
|
||||
|
||||
Returns:
|
||||
True if model is fully cached, False if missing or incomplete
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
model_path = self._get_model_path(model_size)
|
||||
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
|
||||
|
||||
if not repo_cache.exists():
|
||||
return False
|
||||
|
||||
# Check for .incomplete files - if any exist, download is still in progress
|
||||
blobs_dir = repo_cache / "blobs"
|
||||
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||
print(f"[_is_model_cached] Found .incomplete files for {model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
# Check that actual model weight files exist in snapshots
|
||||
snapshots_dir = repo_cache / "snapshots"
|
||||
if snapshots_dir.exists():
|
||||
has_weights = (
|
||||
any(snapshots_dir.rglob("*.safetensors")) or
|
||||
any(snapshots_dir.rglob("*.bin")) or
|
||||
any(snapshots_dir.rglob("*.npz"))
|
||||
)
|
||||
if not has_weights:
|
||||
print(f"[_is_model_cached] No model weights found for {model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[_is_model_cached] Error checking cache for {model_size}: {e}")
|
||||
return False
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
"""
|
||||
Lazy load the MLX TTS model.
|
||||
@@ -79,46 +120,63 @@ class MLXTTSBackend:
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
try:
|
||||
from mlx_audio.tts import load
|
||||
|
||||
# Get model path
|
||||
# Get model path BEFORE importing mlx_audio
|
||||
model_path = self._get_model_path(model_size)
|
||||
|
||||
# Set up progress tracking
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
# Start tracking download task
|
||||
task_manager = get_task_manager()
|
||||
task_manager.start_download(model_name)
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback
|
||||
# If cached: filter out non-download progress
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
print(f"Loading MLX TTS model {model_size}...")
|
||||
|
||||
# Initialize progress state
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=1,
|
||||
filename="",
|
||||
status="downloading",
|
||||
)
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
# This provides immediate feedback while HuggingFace fetches metadata
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=0, # Will be updated once actual total is known
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Set up progress callback
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
# IMPORTANT: Patch tqdm BEFORE importing mlx_audio
|
||||
# Otherwise mlx_audio caches reference to original tqdm
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
|
||||
# Use progress tracker during download
|
||||
with tracker.patch_download():
|
||||
# Load MLX model (downloads automatically)
|
||||
# Import mlx_audio AFTER patching tqdm
|
||||
from mlx_audio.tts import load
|
||||
|
||||
# Load MLX model (downloads automatically)
|
||||
try:
|
||||
self.model = load(model_path)
|
||||
finally:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
|
||||
# Mark as complete
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
|
||||
print(f"MLX TTS model {model_size} loaded successfully")
|
||||
|
||||
except ImportError as e:
|
||||
@@ -332,6 +390,47 @@ class MLXSTTBackend:
|
||||
"""Check if model is loaded."""
|
||||
return self.model is not None
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
"""
|
||||
Check if the Whisper model is already cached locally AND fully downloaded.
|
||||
|
||||
Args:
|
||||
model_size: Model size to check
|
||||
|
||||
Returns:
|
||||
True if model is fully cached, False if missing or incomplete
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
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():
|
||||
has_weights = (
|
||||
any(snapshots_dir.rglob("*.safetensors")) or
|
||||
any(snapshots_dir.rglob("*.bin")) or
|
||||
any(snapshots_dir.rglob("*.npz"))
|
||||
)
|
||||
if not has_weights:
|
||||
print(f"[_is_model_cached] No model weights found for whisper-{model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[_is_model_cached] Error checking cache for whisper-{model_size}: {e}")
|
||||
return False
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
"""
|
||||
Lazy load the MLX Whisper model.
|
||||
@@ -354,55 +453,58 @@ class MLXSTTBackend:
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
try:
|
||||
# IMPORTANT: Set up progress tracking BEFORE importing mlx_audio
|
||||
# This ensures tqdm is patched before any HuggingFace Hub imports
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback and tracker
|
||||
# If cached: filter out non-download progress
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
# Patch tqdm BEFORE importing mlx_audio
|
||||
# This is critical because mlx_audio imports huggingface_hub which imports tqdm
|
||||
print("[DEBUG] Starting tqdm patch BEFORE mlx_audio import")
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
print("[DEBUG] tqdm patched, now importing mlx_audio")
|
||||
|
||||
# NOW import mlx_audio - it will use our patched tqdm
|
||||
# Import mlx_audio
|
||||
from mlx_audio.stt import load
|
||||
|
||||
# MLX Whisper uses the standard OpenAI models
|
||||
model_name = f"openai/whisper-{model_size}"
|
||||
|
||||
# Start tracking download task
|
||||
task_manager = get_task_manager()
|
||||
task_manager.start_download(progress_model_name)
|
||||
|
||||
print(f"Loading MLX Whisper model {model_size}...")
|
||||
|
||||
# Initialize progress state
|
||||
progress_manager.update_progress(
|
||||
model_name=progress_model_name,
|
||||
current=0,
|
||||
total=1,
|
||||
filename="",
|
||||
status="downloading",
|
||||
)
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(progress_model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
progress_manager.update_progress(
|
||||
model_name=progress_model_name,
|
||||
current=0,
|
||||
total=0,
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Load the model (tqdm is already patched from above)
|
||||
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||
try:
|
||||
self.model = load(model_name)
|
||||
finally:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
self.model_size = model_size
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(progress_model_name)
|
||||
task_manager.complete_download(progress_model_name)
|
||||
|
||||
# Mark as complete
|
||||
progress_manager.mark_complete(progress_model_name)
|
||||
task_manager.complete_download(progress_model_name)
|
||||
self.model_size = model_size
|
||||
|
||||
print(f"MLX Whisper model {model_size} loaded successfully")
|
||||
|
||||
|
||||
@@ -58,6 +58,46 @@ class PyTorchTTSBackend:
|
||||
|
||||
return hf_model_map[model_size]
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
"""
|
||||
Check if the model is already cached locally AND fully downloaded.
|
||||
|
||||
Args:
|
||||
model_size: Model size to check
|
||||
|
||||
Returns:
|
||||
True if model is fully cached, False if missing or incomplete
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
model_path = self._get_model_path(model_size)
|
||||
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
|
||||
|
||||
if not repo_cache.exists():
|
||||
return False
|
||||
|
||||
# Check for .incomplete files - if any exist, download is still in progress
|
||||
blobs_dir = repo_cache / "blobs"
|
||||
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||
print(f"[_is_model_cached] Found .incomplete files for {model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
# Check that actual model weight files exist in snapshots
|
||||
snapshots_dir = repo_cache / "snapshots"
|
||||
if snapshots_dir.exists():
|
||||
has_weights = (
|
||||
any(snapshots_dir.rglob("*.safetensors")) or
|
||||
any(snapshots_dir.rglob("*.bin"))
|
||||
)
|
||||
if not has_weights:
|
||||
print(f"[_is_model_cached] No model weights found for {model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[_is_model_cached] Error checking cache for {model_size}: {e}")
|
||||
return False
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
"""
|
||||
Lazy load the TTS model with automatic downloading from HuggingFace Hub.
|
||||
@@ -85,20 +125,24 @@ class PyTorchTTSBackend:
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
try:
|
||||
# IMPORTANT: Set up progress tracking BEFORE importing qwen_tts
|
||||
# This ensures tqdm is patched before any HuggingFace Hub imports
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback and tracker
|
||||
# If cached: filter out non-download progress (like "Segment 1/1" during generation)
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
# Patch tqdm BEFORE importing qwen_tts
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
|
||||
# NOW import qwen_tts - it will use our patched tqdm
|
||||
# Import qwen_tts
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
# Get model path (local or HuggingFace Hub ID)
|
||||
@@ -106,20 +150,21 @@ class PyTorchTTSBackend:
|
||||
|
||||
print(f"Loading TTS model {model_size} on {self.device}...")
|
||||
|
||||
# Start tracking download task
|
||||
task_manager = get_task_manager()
|
||||
task_manager.start_download(model_name)
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(model_name)
|
||||
|
||||
# Initialize progress state to show download has started
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=1, # Set to 1 initially, will be updated by callback
|
||||
filename="",
|
||||
status="downloading",
|
||||
)
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=0, # Will be updated once actual total is known
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Load the model (tqdm is already patched from above)
|
||||
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||
try:
|
||||
self.model = Qwen3TTSModel.from_pretrained(
|
||||
model_path,
|
||||
@@ -130,9 +175,10 @@ class PyTorchTTSBackend:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
# Mark as complete
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
@@ -321,6 +367,46 @@ class PyTorchSTTBackend:
|
||||
"""Check if model is loaded."""
|
||||
return self.model is not None
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
"""
|
||||
Check if the Whisper model is already cached locally AND fully downloaded.
|
||||
|
||||
Args:
|
||||
model_size: Model size to check
|
||||
|
||||
Returns:
|
||||
True if model is fully cached, False if missing or incomplete
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
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():
|
||||
has_weights = (
|
||||
any(snapshots_dir.rglob("*.safetensors")) or
|
||||
any(snapshots_dir.rglob("*.bin"))
|
||||
)
|
||||
if not has_weights:
|
||||
print(f"[_is_model_cached] No model weights found for whisper-{model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[_is_model_cached] Error checking cache for whisper-{model_size}: {e}")
|
||||
return False
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
"""
|
||||
Lazy load the Whisper model.
|
||||
@@ -349,14 +435,18 @@ class PyTorchSTTBackend:
|
||||
"""Synchronous model loading."""
|
||||
print(f"[DEBUG] _load_model_sync called for Whisper {model_size}")
|
||||
try:
|
||||
# IMPORTANT: Set up progress tracking BEFORE importing transformers
|
||||
# This ensures tqdm is patched before any HuggingFace Hub imports
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback and tracker
|
||||
# If cached: filter out non-download progress
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
# Patch tqdm BEFORE importing transformers
|
||||
print("[DEBUG] Starting tqdm patch BEFORE transformers import")
|
||||
@@ -364,31 +454,29 @@ class PyTorchSTTBackend:
|
||||
tracker_context.__enter__()
|
||||
print("[DEBUG] tqdm patched, now importing transformers")
|
||||
|
||||
# NOW import transformers - it will use our patched tqdm
|
||||
# Import transformers
|
||||
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||
|
||||
model_name = f"openai/whisper-{model_size}"
|
||||
print(f"[DEBUG] Model name: {model_name}")
|
||||
|
||||
# Start tracking download task
|
||||
task_manager = get_task_manager()
|
||||
task_manager.start_download(progress_model_name)
|
||||
print(f"[DEBUG] Task manager started download")
|
||||
|
||||
print(f"Loading Whisper model {model_size} on {self.device}...")
|
||||
|
||||
# Initialize progress state to show download has started
|
||||
print(f"[DEBUG] Calling update_progress...")
|
||||
progress_manager.update_progress(
|
||||
model_name=progress_model_name,
|
||||
current=0,
|
||||
total=1, # Set to 1 initially, will be updated by callback
|
||||
filename="",
|
||||
status="downloading",
|
||||
)
|
||||
print(f"[DEBUG] update_progress called, listeners: {len(progress_manager._listeners.get(progress_model_name, []))}")
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(progress_model_name)
|
||||
|
||||
# Load models (tqdm is already patched from above)
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
progress_manager.update_progress(
|
||||
model_name=progress_model_name,
|
||||
current=0,
|
||||
total=0, # Will be updated once actual total is known
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Load models (tqdm is patched, but filters out non-download progress)
|
||||
try:
|
||||
self.processor = WhisperProcessor.from_pretrained(model_name)
|
||||
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
||||
@@ -396,13 +484,14 @@ class PyTorchSTTBackend:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(progress_model_name)
|
||||
task_manager.complete_download(progress_model_name)
|
||||
|
||||
self.model.to(self.device)
|
||||
self.model_size = model_size
|
||||
|
||||
# Mark as complete
|
||||
progress_manager.mark_complete(progress_model_name)
|
||||
task_manager.complete_download(progress_model_name)
|
||||
|
||||
print(f"Whisper model {model_size} loaded successfully")
|
||||
|
||||
except Exception as e:
|
||||
|
||||
+44
-17
@@ -30,7 +30,7 @@ def build_server():
|
||||
args.extend(['--paths', str(qwen_tts_path)])
|
||||
print(f"Using local qwen_tts source from: {qwen_tts_path}")
|
||||
|
||||
# Add common hidden imports
|
||||
# Add common hidden imports (always included)
|
||||
args.extend([
|
||||
'--hidden-import', 'backend',
|
||||
'--hidden-import', 'backend.main',
|
||||
@@ -42,38 +42,42 @@ def build_server():
|
||||
'--hidden-import', 'backend.tts',
|
||||
'--hidden-import', 'backend.transcribe',
|
||||
'--hidden-import', 'backend.platform_detect',
|
||||
'--hidden-import', 'backend.backends',
|
||||
'--hidden-import', 'backend.backends.pytorch_backend',
|
||||
'--hidden-import', 'backend.providers',
|
||||
'--hidden-import', 'backend.providers.base',
|
||||
'--hidden-import', 'backend.providers.bundled',
|
||||
'--hidden-import', 'backend.providers.types',
|
||||
'--hidden-import', 'backend.utils.audio',
|
||||
'--hidden-import', 'backend.utils.cache',
|
||||
'--hidden-import', 'backend.utils.progress',
|
||||
'--hidden-import', 'backend.utils.hf_progress',
|
||||
'--hidden-import', 'backend.utils.validation',
|
||||
'--hidden-import', 'torch',
|
||||
'--hidden-import', 'transformers',
|
||||
'--hidden-import', 'numpy',
|
||||
'--hidden-import', 'numpy.core',
|
||||
'--hidden-import', 'numpy.core._multiarray_umath',
|
||||
'--hidden-import', 'scipy',
|
||||
'--hidden-import', 'scipy.signal',
|
||||
'--hidden-import', 'fastapi',
|
||||
'--hidden-import', 'uvicorn',
|
||||
'--hidden-import', 'sqlalchemy',
|
||||
'--hidden-import', 'librosa',
|
||||
'--hidden-import', 'soundfile',
|
||||
'--hidden-import', 'qwen_tts',
|
||||
'--hidden-import', 'qwen_tts.inference',
|
||||
'--hidden-import', 'qwen_tts.inference.qwen3_tts_model',
|
||||
'--hidden-import', 'qwen_tts.inference.qwen3_tts_tokenizer',
|
||||
'--hidden-import', 'qwen_tts.core',
|
||||
'--hidden-import', 'qwen_tts.cli',
|
||||
'--copy-metadata', 'qwen-tts',
|
||||
'--collect-submodules', 'qwen_tts',
|
||||
'--collect-data', 'qwen_tts',
|
||||
# Fix for pkg_resources and jaraco namespace packages
|
||||
'--hidden-import', 'pkg_resources.extern',
|
||||
'--collect-submodules', 'jaraco',
|
||||
# Asyncio and threading support for PyInstaller
|
||||
'--hidden-import', 'asyncio',
|
||||
'--hidden-import', 'asyncio.subprocess',
|
||||
'--hidden-import', 'concurrent.futures',
|
||||
'--hidden-import', 'concurrent.futures.thread',
|
||||
])
|
||||
|
||||
# Add MLX-specific imports if building on Apple Silicon
|
||||
# Platform-specific TTS backend handling
|
||||
system = platform.system()
|
||||
|
||||
if is_apple_silicon():
|
||||
print("Building for Apple Silicon - including MLX dependencies")
|
||||
print("Building for Apple Silicon - including MLX dependencies (bundled)")
|
||||
args.extend([
|
||||
'--hidden-import', 'backend.backends',
|
||||
'--hidden-import', 'backend.backends.mlx_backend',
|
||||
'--hidden-import', 'mlx',
|
||||
'--hidden-import', 'mlx.core',
|
||||
@@ -87,8 +91,31 @@ def build_server():
|
||||
'--collect-data', 'mlx',
|
||||
'--collect-data', 'mlx_audio',
|
||||
])
|
||||
elif system == "Windows" or (system == "Darwin" and not is_apple_silicon()):
|
||||
# Windows and Intel macOS: Bundle PyTorch CPU provider
|
||||
print(f"Building for {system} - including PyTorch CPU provider (bundled)")
|
||||
args.extend([
|
||||
'--hidden-import', 'backend.backends',
|
||||
'--hidden-import', 'backend.backends.pytorch_backend',
|
||||
'--hidden-import', 'torch',
|
||||
'--hidden-import', 'transformers',
|
||||
'--hidden-import', 'qwen_tts',
|
||||
'--hidden-import', 'qwen_tts.inference',
|
||||
'--hidden-import', 'qwen_tts.inference.qwen3_tts_model',
|
||||
'--hidden-import', 'qwen_tts.inference.qwen3_tts_tokenizer',
|
||||
'--hidden-import', 'qwen_tts.core',
|
||||
'--hidden-import', 'qwen_tts.cli',
|
||||
'--copy-metadata', 'qwen-tts',
|
||||
'--collect-submodules', 'qwen_tts',
|
||||
'--collect-data', 'qwen_tts',
|
||||
])
|
||||
else:
|
||||
print("Building for non-Apple Silicon platform - PyTorch only")
|
||||
# Linux: No bundled provider - users download providers separately
|
||||
print("Building for Linux - no bundled provider (users download separately)")
|
||||
args.extend([
|
||||
'--hidden-import', 'backend.backends',
|
||||
'--hidden-import', 'backend.backends.pytorch_backend',
|
||||
])
|
||||
|
||||
args.extend([
|
||||
'--noconfirm',
|
||||
|
||||
+392
-90
@@ -14,7 +14,6 @@ from datetime import datetime
|
||||
import asyncio
|
||||
import uvicorn
|
||||
import argparse
|
||||
import torch
|
||||
import tempfile
|
||||
import io
|
||||
from pathlib import Path
|
||||
@@ -23,12 +22,22 @@ import asyncio
|
||||
import signal
|
||||
import os
|
||||
|
||||
# Optional torch import - not available on all platforms (e.g. Windows/Linux without bundled provider)
|
||||
try:
|
||||
import torch
|
||||
TORCH_AVAILABLE = True
|
||||
except ImportError:
|
||||
torch = None # type: ignore
|
||||
TORCH_AVAILABLE = False
|
||||
|
||||
from . import database, models, profiles, history, tts, transcribe, config, export_import, channels, stories, __version__
|
||||
from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||
from .utils.progress import get_progress_manager
|
||||
from .utils.tasks import get_task_manager
|
||||
from .utils.cache import clear_voice_prompt_cache
|
||||
from .platform_detect import get_backend_type
|
||||
from .providers import get_provider_manager
|
||||
from .providers.types import ProviderType
|
||||
|
||||
app = FastAPI(
|
||||
title="voicebox API",
|
||||
@@ -50,10 +59,8 @@ app.add_middleware(
|
||||
# ROOT & HEALTH ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint."""
|
||||
return {"message": "voicebox API", "version": __version__}
|
||||
# Root endpoint removed - web UI served at / instead
|
||||
# API info available at /health
|
||||
|
||||
|
||||
@app.post("/shutdown")
|
||||
@@ -67,23 +74,48 @@ async def shutdown():
|
||||
return {"message": "Shutting down..."}
|
||||
|
||||
|
||||
@app.get("/system/folders", response_model=models.FolderPathsResponse)
|
||||
async def get_system_folders():
|
||||
"""Get system folder paths for data, models, and providers."""
|
||||
from huggingface_hub import constants as hf_constants
|
||||
from .providers.installer import _get_providers_dir
|
||||
|
||||
return models.FolderPathsResponse(
|
||||
data_dir=str(config.get_data_dir().absolute()),
|
||||
models_dir=str(Path(hf_constants.HF_HUB_CACHE).absolute()),
|
||||
providers_dir=str(_get_providers_dir().absolute()),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health", response_model=models.HealthResponse)
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
from huggingface_hub import hf_hub_download, constants as hf_constants
|
||||
from huggingface_hub import constants as hf_constants
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
tts_model = tts.get_tts_model()
|
||||
# Try to get TTS model provider, but it may not be available if dependencies aren't installed
|
||||
tts_model = None
|
||||
try:
|
||||
tts_model = await tts.get_tts_model_async()
|
||||
except ImportError as e:
|
||||
# Provider dependencies not available (e.g., PyTorch not bundled on this platform)
|
||||
# This is expected on Windows/Linux builds without a bundled provider
|
||||
print(f"Provider not available: {e}")
|
||||
|
||||
backend_type = get_backend_type()
|
||||
|
||||
# Check for GPU availability (CUDA or MPS)
|
||||
has_cuda = torch.cuda.is_available()
|
||||
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
||||
# PyTorch might not be available if no provider is bundled
|
||||
has_cuda = False
|
||||
has_mps = False
|
||||
if TORCH_AVAILABLE and torch is not None:
|
||||
has_cuda = torch.cuda.is_available()
|
||||
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
||||
|
||||
gpu_available = has_cuda or has_mps
|
||||
|
||||
gpu_type = None
|
||||
if has_cuda:
|
||||
if has_cuda and torch is not None:
|
||||
gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})"
|
||||
elif has_mps:
|
||||
gpu_type = "MPS (Apple Silicon)"
|
||||
@@ -91,26 +123,27 @@ async def health():
|
||||
gpu_type = "Metal (Apple Silicon via MLX)"
|
||||
|
||||
vram_used = None
|
||||
if has_cuda:
|
||||
if has_cuda and torch is not None:
|
||||
vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB
|
||||
|
||||
|
||||
# Check if model is loaded - use the same logic as model status endpoint
|
||||
model_loaded = False
|
||||
model_size = None
|
||||
try:
|
||||
# Use the same check as model status endpoint
|
||||
if tts_model.is_loaded():
|
||||
model_loaded = True
|
||||
# Get the actual loaded model size
|
||||
# Check _current_model_size first (more reliable for actually loaded models)
|
||||
model_size = getattr(tts_model, '_current_model_size', None)
|
||||
if not model_size:
|
||||
# Fallback to model_size attribute (which should be set when model loads)
|
||||
model_size = getattr(tts_model, 'model_size', None)
|
||||
except Exception:
|
||||
# If there's an error checking, assume not loaded
|
||||
model_loaded = False
|
||||
model_size = None
|
||||
if tts_model is not None:
|
||||
try:
|
||||
# Use the same check as model status endpoint
|
||||
if tts_model.is_loaded():
|
||||
model_loaded = True
|
||||
# Get the actual loaded model size
|
||||
# Check _current_model_size first (more reliable for actually loaded models)
|
||||
model_size = getattr(tts_model, '_current_model_size', None)
|
||||
if not model_size:
|
||||
# Fallback to model_size attribute (which should be set when model loads)
|
||||
model_size = getattr(tts_model, 'model_size', None)
|
||||
except Exception:
|
||||
# If there's an error checking, assume not loaded
|
||||
model_loaded = False
|
||||
model_size = None
|
||||
|
||||
# Check if default model is downloaded (cached)
|
||||
model_downloaded = None
|
||||
@@ -549,7 +582,7 @@ async def generate_speech(
|
||||
)
|
||||
|
||||
# Generate audio
|
||||
tts_model = tts.get_tts_model()
|
||||
tts_model = await tts.get_tts_model_async()
|
||||
# Load the requested model size if different from current (async to not block)
|
||||
model_size = data.model_size or "1.7B"
|
||||
|
||||
@@ -1113,8 +1146,8 @@ async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)):
|
||||
async def load_model(model_size: str = "1.7B"):
|
||||
"""Manually load TTS model."""
|
||||
try:
|
||||
tts_model = tts.get_tts_model()
|
||||
await tts_model.load_model_async(model_size)
|
||||
tts_model = await tts.get_tts_model_async()
|
||||
await tts_model.load_model(model_size)
|
||||
return {"message": f"Model {model_size} loaded successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -1156,11 +1189,14 @@ async def get_model_progress(model_name: str):
|
||||
@app.get("/models/status", response_model=models.ModelStatusListResponse)
|
||||
async def get_model_status():
|
||||
"""Get status of all available models."""
|
||||
from huggingface_hub import hf_hub_download, constants as hf_constants
|
||||
from huggingface_hub import constants as hf_constants
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
backend_type = get_backend_type()
|
||||
task_manager = get_task_manager()
|
||||
|
||||
# Get set of currently downloading model names
|
||||
active_download_names = {task.model_name for task in task_manager.get_active_downloads()}
|
||||
|
||||
# Try to import scan_cache_dir (might not be available in older versions)
|
||||
try:
|
||||
@@ -1169,10 +1205,10 @@ async def get_model_status():
|
||||
except ImportError:
|
||||
use_scan_cache = False
|
||||
|
||||
def check_tts_loaded(model_size: str):
|
||||
async def check_tts_loaded(model_size: str):
|
||||
"""Check if TTS model is loaded with specific size."""
|
||||
try:
|
||||
tts_model = tts.get_tts_model()
|
||||
tts_model = await tts.get_tts_model_async()
|
||||
return tts_model.is_loaded() and getattr(tts_model, 'model_size', None) == model_size
|
||||
except Exception:
|
||||
return False
|
||||
@@ -1189,10 +1225,11 @@ async def get_model_status():
|
||||
if backend_type == "mlx":
|
||||
tts_1_7b_id = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
|
||||
tts_0_6b_id = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16" # Fallback to 1.7B
|
||||
whisper_base_id = "mlx-community/whisper-base"
|
||||
whisper_small_id = "mlx-community/whisper-small"
|
||||
whisper_medium_id = "mlx-community/whisper-medium"
|
||||
whisper_large_id = "mlx-community/whisper-large"
|
||||
# MLX backend uses openai/whisper-* models, not mlx-community
|
||||
whisper_base_id = "openai/whisper-base"
|
||||
whisper_small_id = "openai/whisper-small"
|
||||
whisper_medium_id = "openai/whisper-medium"
|
||||
whisper_large_id = "openai/whisper-large"
|
||||
else:
|
||||
tts_1_7b_id = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||
tts_0_6b_id = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
|
||||
@@ -1207,14 +1244,14 @@ async def get_model_status():
|
||||
"display_name": "Qwen TTS 1.7B",
|
||||
"hf_repo_id": tts_1_7b_id,
|
||||
"model_size": "1.7B",
|
||||
"check_loaded": lambda: check_tts_loaded("1.7B"),
|
||||
"check_loaded": lambda: check_tts_loaded("1.7B"), # Async function
|
||||
},
|
||||
{
|
||||
"model_name": "qwen-tts-0.6B",
|
||||
"display_name": "Qwen TTS 0.6B",
|
||||
"hf_repo_id": tts_0_6b_id,
|
||||
"model_size": "0.6B",
|
||||
"check_loaded": lambda: check_tts_loaded("0.6B"),
|
||||
"check_loaded": lambda: check_tts_loaded("0.6B"), # Async function
|
||||
},
|
||||
{
|
||||
"model_name": "whisper-base",
|
||||
@@ -1246,6 +1283,13 @@ async def get_model_status():
|
||||
},
|
||||
]
|
||||
|
||||
# Build a mapping of model_name -> hf_repo_id so we can check if shared repos are downloading
|
||||
model_to_repo = {cfg["model_name"]: cfg["hf_repo_id"] for cfg in model_configs}
|
||||
|
||||
# Get the set of hf_repo_ids that are currently being downloaded
|
||||
# This handles the case where multiple models share the same repo (e.g., 0.6B and 1.7B on MLX)
|
||||
active_download_repos = {model_to_repo.get(name) for name in active_download_names if name in model_to_repo}
|
||||
|
||||
# Get HuggingFace cache info (if available)
|
||||
cache_info = None
|
||||
if use_scan_cache:
|
||||
@@ -1268,13 +1312,37 @@ async def get_model_status():
|
||||
repo_id = config["hf_repo_id"]
|
||||
for repo in cache_info.repos:
|
||||
if repo.repo_id == repo_id:
|
||||
downloaded = True
|
||||
# Calculate size from cache info
|
||||
# Check if actual model weight files exist (not just config files)
|
||||
# scan_cache_dir only shows completed files, so check if any are model weights
|
||||
has_model_weights = False
|
||||
for rev in repo.revisions:
|
||||
for f in rev.files:
|
||||
fname = f.file_name.lower()
|
||||
if fname.endswith(('.safetensors', '.bin', '.pt', '.pth', '.npz')):
|
||||
has_model_weights = True
|
||||
break
|
||||
if has_model_weights:
|
||||
break
|
||||
|
||||
# Also check for .incomplete files in blobs directory (downloads in progress)
|
||||
has_incomplete = False
|
||||
try:
|
||||
total_size = sum(revision.size_on_disk for revision in repo.revisions)
|
||||
size_mb = total_size / (1024 * 1024)
|
||||
cache_dir = hf_constants.HF_HUB_CACHE
|
||||
blobs_dir = Path(cache_dir) / ("models--" + repo_id.replace("/", "--")) / "blobs"
|
||||
if blobs_dir.exists():
|
||||
has_incomplete = any(blobs_dir.glob("*.incomplete"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Only mark as downloaded if we have model weights AND no incomplete files
|
||||
if has_model_weights and not has_incomplete:
|
||||
downloaded = True
|
||||
# Calculate size from cache info
|
||||
try:
|
||||
total_size = sum(revision.size_on_disk for revision in repo.revisions)
|
||||
size_mb = total_size / (1024 * 1024)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
# Method 2: Fallback to checking cache directory directly (using HuggingFace's OS-specific cache location)
|
||||
@@ -1284,67 +1352,96 @@ async def get_model_status():
|
||||
repo_cache = Path(cache_dir) / ("models--" + config["hf_repo_id"].replace("/", "--"))
|
||||
|
||||
if repo_cache.exists():
|
||||
# Check for model files (bin, safetensors, or other common model files)
|
||||
# MLX models may use .npz or .safetensors
|
||||
has_model_files = (
|
||||
any(repo_cache.rglob("*.bin")) or
|
||||
any(repo_cache.rglob("*.safetensors")) or
|
||||
any(repo_cache.rglob("*.pt")) or
|
||||
any(repo_cache.rglob("*.pth")) or
|
||||
any(repo_cache.rglob("*.npz")) or
|
||||
any(repo_cache.rglob("model.safetensors.index.json")) or
|
||||
any(repo_cache.rglob("pytorch_model.bin.index.json"))
|
||||
)
|
||||
# Check for .incomplete files - if any exist, download is still in progress
|
||||
blobs_dir = repo_cache / "blobs"
|
||||
has_incomplete = blobs_dir.exists() and any(blobs_dir.glob("*.incomplete"))
|
||||
|
||||
if has_model_files:
|
||||
downloaded = True
|
||||
# Calculate size
|
||||
try:
|
||||
total_size = sum(f.stat().st_size for f in repo_cache.rglob("*") if f.is_file())
|
||||
size_mb = total_size / (1024 * 1024)
|
||||
except Exception:
|
||||
pass
|
||||
if not has_incomplete:
|
||||
# Check for actual model weight files (not just index files)
|
||||
# in the snapshots directory (symlinks to completed blobs)
|
||||
snapshots_dir = repo_cache / "snapshots"
|
||||
has_model_files = False
|
||||
if snapshots_dir.exists():
|
||||
has_model_files = (
|
||||
any(snapshots_dir.rglob("*.bin")) or
|
||||
any(snapshots_dir.rglob("*.safetensors")) or
|
||||
any(snapshots_dir.rglob("*.pt")) or
|
||||
any(snapshots_dir.rglob("*.pth")) or
|
||||
any(snapshots_dir.rglob("*.npz"))
|
||||
)
|
||||
|
||||
if has_model_files:
|
||||
downloaded = True
|
||||
# Calculate size (exclude .incomplete files)
|
||||
try:
|
||||
total_size = sum(
|
||||
f.stat().st_size for f in repo_cache.rglob("*")
|
||||
if f.is_file() and not f.name.endswith('.incomplete')
|
||||
)
|
||||
size_mb = total_size / (1024 * 1024)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Method 3: Try to check if model can be loaded locally (last resort)
|
||||
if not downloaded:
|
||||
try:
|
||||
# Try to download with local_files_only=True to check if cached
|
||||
hf_hub_download(
|
||||
repo_id=config["hf_repo_id"],
|
||||
filename="config.json", # Try a common file
|
||||
local_files_only=True,
|
||||
)
|
||||
downloaded = True
|
||||
except Exception:
|
||||
# File not found locally, model not downloaded
|
||||
pass
|
||||
# Method 3 removed - checking for config.json is too lenient
|
||||
# Methods 1 and 2 properly verify that model weight files exist
|
||||
|
||||
# Check if loaded in memory
|
||||
try:
|
||||
loaded = config["check_loaded"]()
|
||||
check_func = config["check_loaded"]
|
||||
if asyncio.iscoroutinefunction(check_func):
|
||||
loaded = await check_func()
|
||||
else:
|
||||
result = check_func()
|
||||
# Handle lambdas that return coroutines
|
||||
if asyncio.iscoroutine(result):
|
||||
loaded = await result
|
||||
else:
|
||||
loaded = result
|
||||
except Exception:
|
||||
loaded = False
|
||||
|
||||
# Check if this model (or its shared repo) is currently being downloaded
|
||||
is_downloading = config["hf_repo_id"] in active_download_repos
|
||||
|
||||
# If downloading, don't report as downloaded (partial files exist)
|
||||
if is_downloading:
|
||||
downloaded = False
|
||||
size_mb = None # Don't show partial size during download
|
||||
|
||||
statuses.append(models.ModelStatus(
|
||||
model_name=config["model_name"],
|
||||
display_name=config["display_name"],
|
||||
downloaded=downloaded,
|
||||
downloading=is_downloading,
|
||||
size_mb=size_mb,
|
||||
loaded=loaded,
|
||||
))
|
||||
except Exception as e:
|
||||
# If check fails, try to at least check if loaded
|
||||
try:
|
||||
loaded = config["check_loaded"]()
|
||||
check_func = config["check_loaded"]
|
||||
if asyncio.iscoroutinefunction(check_func):
|
||||
loaded = await check_func()
|
||||
else:
|
||||
result = check_func()
|
||||
# Handle lambdas that return coroutines
|
||||
if asyncio.iscoroutine(result):
|
||||
loaded = await result
|
||||
else:
|
||||
loaded = result
|
||||
except Exception:
|
||||
loaded = False
|
||||
|
||||
# Check if this model (or its shared repo) is currently being downloaded
|
||||
is_downloading = config["hf_repo_id"] in active_download_repos
|
||||
|
||||
statuses.append(models.ModelStatus(
|
||||
model_name=config["model_name"],
|
||||
display_name=config["display_name"],
|
||||
downloaded=False, # Assume not downloaded if check failed
|
||||
downloading=is_downloading,
|
||||
size_mb=None,
|
||||
loaded=loaded,
|
||||
))
|
||||
@@ -1358,15 +1455,26 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
||||
import asyncio
|
||||
|
||||
task_manager = get_task_manager()
|
||||
progress_manager = get_progress_manager()
|
||||
|
||||
async def load_tts_model_1_7b():
|
||||
"""Load 1.7B TTS model."""
|
||||
tts_model = await tts.get_tts_model_async()
|
||||
await tts_model.load_model("1.7B")
|
||||
|
||||
async def load_tts_model_0_6b():
|
||||
"""Load 0.6B TTS model."""
|
||||
tts_model = await tts.get_tts_model_async()
|
||||
await tts_model.load_model("0.6B")
|
||||
|
||||
model_configs = {
|
||||
"qwen-tts-1.7B": {
|
||||
"model_size": "1.7B",
|
||||
"load_func": lambda: tts.get_tts_model().load_model("1.7B"),
|
||||
"load_func": load_tts_model_1_7b,
|
||||
},
|
||||
"qwen-tts-0.6B": {
|
||||
"model_size": "0.6B",
|
||||
"load_func": lambda: tts.get_tts_model().load_model("0.6B"),
|
||||
"load_func": load_tts_model_0_6b,
|
||||
},
|
||||
"whisper-base": {
|
||||
"model_size": "base",
|
||||
@@ -1405,6 +1513,18 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
||||
|
||||
# Start tracking download
|
||||
task_manager.start_download(request.model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send.
|
||||
# This fixes a race condition where the frontend connects to SSE before
|
||||
# any progress callbacks have fired (especially for large models like Qwen
|
||||
# where huggingface_hub takes time to fetch metadata for all files).
|
||||
progress_manager.update_progress(
|
||||
model_name=request.model_name,
|
||||
current=0,
|
||||
total=0, # Will be updated once actual total is known
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Start download in background task (don't await)
|
||||
asyncio.create_task(download_in_background())
|
||||
@@ -1413,6 +1533,171 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
||||
return {"message": f"Model {request.model_name} download started"}
|
||||
|
||||
|
||||
# ============================================
|
||||
# PROVIDER ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@app.get("/providers")
|
||||
async def list_providers():
|
||||
"""List all available provider types."""
|
||||
manager = get_provider_manager()
|
||||
installed = await manager.list_installed()
|
||||
|
||||
# Get info for all known provider types
|
||||
all_providers = [
|
||||
"apple-mlx",
|
||||
"bundled-pytorch",
|
||||
"pytorch-cpu",
|
||||
"pytorch-cuda",
|
||||
"remote",
|
||||
"openai",
|
||||
]
|
||||
|
||||
providers_info = []
|
||||
for provider_type in all_providers:
|
||||
info = await manager.get_provider_info(provider_type)
|
||||
providers_info.append(info)
|
||||
|
||||
return {
|
||||
"providers": providers_info,
|
||||
"installed": installed,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/providers/installed")
|
||||
async def list_installed_providers():
|
||||
"""List installed provider types."""
|
||||
manager = get_provider_manager()
|
||||
installed = await manager.list_installed()
|
||||
return {"installed": installed}
|
||||
|
||||
|
||||
@app.get("/providers/active")
|
||||
async def get_active_provider():
|
||||
"""Get information about the currently active provider."""
|
||||
manager = get_provider_manager()
|
||||
provider = await manager.get_active_provider()
|
||||
|
||||
health = await provider.health()
|
||||
status = await provider.status()
|
||||
|
||||
return {
|
||||
"provider": health["provider"],
|
||||
"health": health,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/providers/start")
|
||||
async def start_provider(data: dict):
|
||||
"""Start a specific provider."""
|
||||
provider_type = data.get("provider_type")
|
||||
if not provider_type:
|
||||
raise HTTPException(status_code=400, detail="provider_type is required")
|
||||
|
||||
manager = get_provider_manager()
|
||||
try:
|
||||
await manager.start_provider(provider_type)
|
||||
provider = await manager.get_active_provider()
|
||||
health = await provider.health()
|
||||
return {
|
||||
"message": f"Provider {provider_type} started",
|
||||
"provider": health,
|
||||
}
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(status_code=501, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/providers/stop")
|
||||
async def stop_provider():
|
||||
"""Stop the currently active provider."""
|
||||
manager = get_provider_manager()
|
||||
await manager.stop_provider()
|
||||
return {"message": "Provider stopped"}
|
||||
|
||||
|
||||
@app.post("/providers/download")
|
||||
async def download_provider_endpoint(data: dict):
|
||||
"""Download a provider binary."""
|
||||
from .providers.installer import download_provider
|
||||
|
||||
provider_type = data.get("provider_type")
|
||||
if not provider_type:
|
||||
raise HTTPException(status_code=400, detail="provider_type is required")
|
||||
|
||||
if provider_type not in ["pytorch-cpu", "pytorch-cuda"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider type {provider_type} cannot be downloaded"
|
||||
)
|
||||
|
||||
try:
|
||||
# Start download in background
|
||||
asyncio.create_task(download_provider(provider_type))
|
||||
return {
|
||||
"message": f"Provider {provider_type} download started",
|
||||
"provider_type": provider_type,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/providers/download/progress/{provider_type}")
|
||||
async def get_provider_download_progress(provider_type: str):
|
||||
"""Get provider download progress via Server-Sent Events."""
|
||||
from fastapi.responses import StreamingResponse
|
||||
from .utils.progress import get_progress_manager
|
||||
|
||||
progress_manager = get_progress_manager()
|
||||
|
||||
async def event_generator():
|
||||
"""Generate SSE events for provider download progress."""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
last_progress = None
|
||||
|
||||
while True:
|
||||
progress = progress_manager.get_progress(provider_type)
|
||||
|
||||
if progress and progress != last_progress:
|
||||
yield f"data: {json.dumps(progress)}\n\n"
|
||||
last_progress = progress
|
||||
|
||||
if progress.get("status") in ["complete", "error"]:
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@app.delete("/providers/{provider_type}")
|
||||
async def delete_provider_endpoint(provider_type: str):
|
||||
"""Delete an installed provider."""
|
||||
from .providers.installer import delete_provider
|
||||
|
||||
if provider_type not in ["pytorch-cpu", "pytorch-cuda"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider type {provider_type} cannot be deleted"
|
||||
)
|
||||
|
||||
deleted = delete_provider(provider_type)
|
||||
|
||||
if deleted:
|
||||
return {"message": f"Provider {provider_type} deleted successfully"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Provider {provider_type} not found"
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/models/{model_name}")
|
||||
async def delete_model(model_name: str):
|
||||
"""Delete a downloaded model from the HuggingFace cache."""
|
||||
@@ -1463,9 +1748,9 @@ async def delete_model(model_name: str):
|
||||
try:
|
||||
# Check if model is loaded and unload it first
|
||||
if config["model_type"] == "tts":
|
||||
tts_model = tts.get_tts_model()
|
||||
if tts_model.is_loaded() and tts_model.model_size == config["model_size"]:
|
||||
tts.unload_tts_model()
|
||||
tts_model = await tts.get_tts_model_async()
|
||||
if tts_model.is_loaded() and getattr(tts_model, 'model_size', None) == config["model_size"]:
|
||||
tts_model.unload_model()
|
||||
elif config["model_type"] == "whisper":
|
||||
whisper_model = transcribe.get_whisper_model()
|
||||
if whisper_model.is_loaded() and whisper_model.model_size == config["model_size"]:
|
||||
@@ -1575,6 +1860,16 @@ async def get_active_tasks():
|
||||
)
|
||||
|
||||
|
||||
# ============================================
|
||||
# WEB UI STATIC FILES
|
||||
# ============================================
|
||||
|
||||
# Serve web UI at root if dist directory exists
|
||||
_web_dist_path = Path(__file__).parent.parent / "web" / "dist"
|
||||
if _web_dist_path.exists():
|
||||
app.mount("/", StaticFiles(directory=str(_web_dist_path), html=True), name="web")
|
||||
|
||||
|
||||
# ============================================
|
||||
# STARTUP & SHUTDOWN
|
||||
# ============================================
|
||||
@@ -1582,11 +1877,12 @@ async def get_active_tasks():
|
||||
def _get_gpu_status() -> str:
|
||||
"""Get GPU availability status."""
|
||||
backend_type = get_backend_type()
|
||||
if torch.cuda.is_available():
|
||||
return f"CUDA ({torch.cuda.get_device_name(0)})"
|
||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||
return "MPS (Apple Silicon)"
|
||||
elif backend_type == "mlx":
|
||||
if TORCH_AVAILABLE and torch is not None:
|
||||
if torch.cuda.is_available():
|
||||
return f"CUDA ({torch.cuda.get_device_name(0)})"
|
||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||
return "MPS (Apple Silicon)"
|
||||
if backend_type == "mlx":
|
||||
return "Metal (Apple Silicon via MLX)"
|
||||
return "None (CPU only)"
|
||||
|
||||
@@ -1625,8 +1921,14 @@ async def shutdown_event():
|
||||
"""Run on application shutdown."""
|
||||
print("voicebox API shutting down...")
|
||||
# Unload models to free memory
|
||||
tts.unload_tts_model()
|
||||
transcribe.unload_whisper_model()
|
||||
try:
|
||||
tts.unload_tts_model()
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to unload TTS model: {e}")
|
||||
try:
|
||||
transcribe.unload_whisper_model()
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to unload Whisper model: {e}")
|
||||
|
||||
|
||||
# ============================================
|
||||
|
||||
@@ -134,6 +134,7 @@ class ModelStatus(BaseModel):
|
||||
model_name: str
|
||||
display_name: str
|
||||
downloaded: bool
|
||||
downloading: bool = False # True if download is in progress
|
||||
size_mb: Optional[float] = None
|
||||
loaded: bool = False
|
||||
|
||||
@@ -169,6 +170,13 @@ class ActiveTasksResponse(BaseModel):
|
||||
generations: List[ActiveGenerationTask]
|
||||
|
||||
|
||||
class FolderPathsResponse(BaseModel):
|
||||
"""Response model for system folder paths."""
|
||||
data_dir: str
|
||||
models_dir: str
|
||||
providers_dir: str
|
||||
|
||||
|
||||
class AudioChannelCreate(BaseModel):
|
||||
"""Request model for creating an audio channel."""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
Provider management system for TTS providers.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
from .base import TTSProvider
|
||||
from .types import ProviderType
|
||||
from .bundled import BundledProvider
|
||||
from .local import LocalProvider
|
||||
from .installer import get_provider_binary_path, _get_providers_dir
|
||||
from ..config import get_data_dir
|
||||
import subprocess
|
||||
import socket
|
||||
|
||||
|
||||
class ProviderManager:
|
||||
"""Manages TTS provider lifecycle."""
|
||||
|
||||
def __init__(self):
|
||||
self.active_provider: Optional[TTSProvider] = None
|
||||
self._default_provider: Optional[TTSProvider] = None
|
||||
self._provider_process: Optional[subprocess.Popen] = None
|
||||
self._provider_port: Optional[int] = None
|
||||
|
||||
def _get_default_provider(self) -> TTSProvider:
|
||||
"""Get the default bundled provider."""
|
||||
if self._default_provider is None:
|
||||
self._default_provider = BundledProvider()
|
||||
return self._default_provider
|
||||
|
||||
async def get_active_provider(self) -> TTSProvider:
|
||||
"""
|
||||
Get the currently active provider.
|
||||
|
||||
Returns:
|
||||
Active TTS provider instance
|
||||
"""
|
||||
if self.active_provider is None:
|
||||
# Default to bundled provider
|
||||
self.active_provider = self._get_default_provider()
|
||||
return self.active_provider
|
||||
|
||||
async def start_provider(self, provider_type: str) -> None:
|
||||
"""
|
||||
Start a TTS provider.
|
||||
|
||||
Args:
|
||||
provider_type: Type of provider to start
|
||||
"""
|
||||
if provider_type == "apple-mlx":
|
||||
# Use bundled MLX provider
|
||||
self.active_provider = self._get_default_provider()
|
||||
elif provider_type in ["pytorch-cpu", "pytorch-cuda"]:
|
||||
# Try to start external provider subprocess if binary exists
|
||||
provider_path = get_provider_binary_path(provider_type)
|
||||
if provider_path and provider_path.exists():
|
||||
# External downloaded provider exists, start it
|
||||
# Find a free port
|
||||
port = self._get_free_port()
|
||||
|
||||
# Start provider subprocess with stdout/stderr capture
|
||||
from ..config import get_data_dir
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info(f"Starting provider {provider_type} on port {port}")
|
||||
logger.info(f"Provider binary: {provider_path}")
|
||||
logger.info(f"Data directory: {get_data_dir()}")
|
||||
|
||||
# Create log files for provider output (easier debugging on Windows)
|
||||
logs_dir = get_data_dir() / "logs"
|
||||
logs_dir.mkdir(exist_ok=True)
|
||||
stdout_log = logs_dir / f"{provider_type}-stdout.log"
|
||||
stderr_log = logs_dir / f"{provider_type}-stderr.log"
|
||||
|
||||
logger.info(f"Provider logs will be written to: {logs_dir}")
|
||||
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
str(provider_path),
|
||||
"--port", str(port),
|
||||
"--data-dir", str(get_data_dir()),
|
||||
],
|
||||
stdout=open(stdout_log, 'w'),
|
||||
stderr=open(stderr_log, 'w'),
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
# Wait for provider to be ready
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
try:
|
||||
await self._wait_for_provider_health(base_url, timeout=30)
|
||||
except TimeoutError as e:
|
||||
# Read log files for debugging (works on all platforms unlike select)
|
||||
stdout_content = ""
|
||||
stderr_content = ""
|
||||
|
||||
# Try to read available output (works on Windows and Unix)
|
||||
try:
|
||||
# Use non-blocking read with timeout
|
||||
import threading
|
||||
import queue
|
||||
|
||||
def enqueue_output(stream, queue):
|
||||
try:
|
||||
for line in iter(stream.readline, ''):
|
||||
queue.put(line)
|
||||
except:
|
||||
pass
|
||||
|
||||
stdout_queue = queue.Queue()
|
||||
stderr_queue = queue.Queue()
|
||||
|
||||
if process.stdout:
|
||||
t = threading.Thread(target=enqueue_output, args=(process.stdout, stdout_queue))
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
if process.stderr:
|
||||
t2 = threading.Thread(target=enqueue_output, args=(process.stderr, stderr_queue))
|
||||
t2.daemon = True
|
||||
t2.start()
|
||||
|
||||
# Give threads a moment to read
|
||||
import time
|
||||
time.sleep(0.5)
|
||||
|
||||
# Collect output
|
||||
while not stdout_queue.empty():
|
||||
stdout_lines.append(stdout_queue.get_nowait())
|
||||
while not stderr_queue.empty():
|
||||
stderr_lines.append(stderr_queue.get_nowait())
|
||||
except Exception as ex:
|
||||
logger.warning(f"Could not capture subprocess output: {ex}")
|
||||
|
||||
logger.error(f"Provider failed to start within 30 seconds")
|
||||
logger.error(f"Check logs at: {logs_dir}")
|
||||
if stdout_content:
|
||||
logger.error(f"Stdout: {stdout_content[-2000:]}") # Last 2000 chars
|
||||
if stderr_content:
|
||||
logger.error(f"Stderr: {stderr_content[-2000:]}") # Last 2000 chars
|
||||
|
||||
# Terminate the process
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
# Raise with log file location for user
|
||||
raise TimeoutError(
|
||||
f"Provider {provider_type} failed to start. Check logs at: {logs_dir}"
|
||||
)
|
||||
|
||||
# Create LocalProvider instance
|
||||
self.active_provider = LocalProvider(base_url)
|
||||
self._provider_process = process
|
||||
self._provider_port = port
|
||||
|
||||
# Logs are written directly to files (stdout_log, stderr_log)
|
||||
# No need for background task - users can check {logs_dir} for debugging
|
||||
else:
|
||||
# No external binary, use bundled provider (if available)
|
||||
if provider_type == "pytorch-cpu":
|
||||
# PyTorch CPU can use bundled backend
|
||||
self.active_provider = self._get_default_provider()
|
||||
else:
|
||||
raise ValueError(f"Provider {provider_type} is not installed. Please download it first.")
|
||||
elif provider_type == "remote":
|
||||
# Remote provider - will be implemented in Phase 5
|
||||
raise NotImplementedError("Remote provider not yet implemented")
|
||||
elif provider_type == "openai":
|
||||
# OpenAI provider - will be implemented in Phase 5
|
||||
raise NotImplementedError("OpenAI provider not yet implemented")
|
||||
else:
|
||||
raise ValueError(f"Unknown provider type: {provider_type}")
|
||||
|
||||
async def stop_provider(self) -> None:
|
||||
"""Stop the active provider."""
|
||||
if self.active_provider:
|
||||
# Only stop if it's not the default bundled provider
|
||||
if self.active_provider is not self._default_provider:
|
||||
if hasattr(self.active_provider, 'stop'):
|
||||
await self.active_provider.stop()
|
||||
self.active_provider = None
|
||||
|
||||
# Stop subprocess if running
|
||||
if self._provider_process:
|
||||
self._provider_process.terminate()
|
||||
try:
|
||||
self._provider_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._provider_process.kill()
|
||||
self._provider_process = None
|
||||
self._provider_port = None
|
||||
|
||||
async def list_installed(self) -> list[str]:
|
||||
"""
|
||||
List installed provider types.
|
||||
|
||||
Returns:
|
||||
List of installed provider type strings
|
||||
"""
|
||||
installed = []
|
||||
|
||||
# Bundled providers are always available
|
||||
system = platform.system()
|
||||
machine = platform.machine()
|
||||
|
||||
if system == "Darwin" and machine == "arm64":
|
||||
# Apple Silicon gets MLX bundled
|
||||
installed.append("apple-mlx")
|
||||
elif system == "Windows" or (system == "Darwin" and machine != "arm64"):
|
||||
# Windows and Intel macOS get PyTorch CPU bundled
|
||||
installed.append("pytorch-cpu")
|
||||
# Linux: no bundled provider - users must download
|
||||
|
||||
# Check for downloaded providers by checking if binary path exists
|
||||
for provider_type in ["pytorch-cpu", "pytorch-cuda"]:
|
||||
binary_path = get_provider_binary_path(provider_type)
|
||||
if binary_path and binary_path.exists() and provider_type not in installed:
|
||||
installed.append(provider_type)
|
||||
|
||||
return installed
|
||||
|
||||
async def get_provider_info(self, provider_type: str) -> dict:
|
||||
"""
|
||||
Get information about a provider.
|
||||
|
||||
Args:
|
||||
provider_type: Type of provider
|
||||
|
||||
Returns:
|
||||
Provider information dictionary
|
||||
"""
|
||||
if provider_type in ["apple-mlx", "bundled-pytorch"]:
|
||||
return {
|
||||
"type": provider_type,
|
||||
"name": "Bundled Provider",
|
||||
"installed": True,
|
||||
"size_mb": None, # Bundled, no separate size
|
||||
}
|
||||
elif provider_type == "pytorch-cpu":
|
||||
return {
|
||||
"type": provider_type,
|
||||
"name": "PyTorch CPU",
|
||||
"installed": provider_type in await self.list_installed(),
|
||||
"size_mb": 300,
|
||||
}
|
||||
elif provider_type == "pytorch-cuda":
|
||||
return {
|
||||
"type": provider_type,
|
||||
"name": "PyTorch CUDA",
|
||||
"installed": provider_type in await self.list_installed(),
|
||||
"size_mb": 2400,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"type": provider_type,
|
||||
"name": provider_type,
|
||||
"installed": False,
|
||||
"size_mb": None,
|
||||
}
|
||||
|
||||
|
||||
def _get_free_port(self) -> int:
|
||||
"""Get a free port for the provider server."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('', 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
async def _wait_for_provider_health(self, base_url: str, timeout: int = 30) -> None:
|
||||
"""Wait for provider to become healthy."""
|
||||
import httpx
|
||||
import asyncio
|
||||
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
while True:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
response = await client.get(f"{base_url}/tts/health")
|
||||
if response.status_code == 200:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if asyncio.get_event_loop().time() - start_time > timeout:
|
||||
raise TimeoutError(f"Provider did not become healthy within {timeout} seconds")
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
async def _log_subprocess_output(self, process: subprocess.Popen) -> None:
|
||||
"""Log subprocess stdout and stderr."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def read_stream(stream, prefix):
|
||||
if stream:
|
||||
loop = asyncio.get_event_loop()
|
||||
while True:
|
||||
line = await loop.run_in_executor(None, stream.readline)
|
||||
if not line:
|
||||
break
|
||||
logger.info(f"{prefix}: {line.rstrip()}")
|
||||
|
||||
await asyncio.gather(
|
||||
read_stream(process.stdout, "Provider stdout"),
|
||||
read_stream(process.stderr, "Provider stderr"),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
|
||||
# Global provider manager instance
|
||||
_provider_manager: Optional[ProviderManager] = None
|
||||
|
||||
|
||||
def get_provider_manager() -> ProviderManager:
|
||||
"""Get the global provider manager instance."""
|
||||
global _provider_manager
|
||||
if _provider_manager is None:
|
||||
_provider_manager = ProviderManager()
|
||||
return _provider_manager
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Base protocol for TTS providers.
|
||||
"""
|
||||
|
||||
from typing import Protocol, Optional, Tuple
|
||||
from typing_extensions import runtime_checkable
|
||||
import numpy as np
|
||||
|
||||
from .types import ProviderHealth, ProviderStatus
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TTSProvider(Protocol):
|
||||
"""Protocol for TTS provider implementations."""
|
||||
|
||||
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 speech audio from text.
|
||||
|
||||
Args:
|
||||
text: Text to synthesize
|
||||
voice_prompt: Voice prompt dictionary
|
||||
language: Language code
|
||||
seed: Random seed for reproducibility
|
||||
instruct: Delivery instructions
|
||||
|
||||
Returns:
|
||||
Tuple of (audio_array, sample_rate)
|
||||
"""
|
||||
...
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
audio_path: Path to reference audio file
|
||||
reference_text: Transcript of the audio
|
||||
use_cache: Whether to use cached prompts
|
||||
|
||||
Returns:
|
||||
Tuple of (voice_prompt_dict, was_cached)
|
||||
"""
|
||||
...
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: list[str],
|
||||
reference_texts: list[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
"""
|
||||
Combine multiple voice prompts.
|
||||
|
||||
Args:
|
||||
audio_paths: List of audio file paths
|
||||
reference_texts: List of reference texts
|
||||
|
||||
Returns:
|
||||
Tuple of (combined_audio_array, combined_text)
|
||||
"""
|
||||
...
|
||||
|
||||
async def load_model_async(self, model_size: str) -> None:
|
||||
"""Load TTS model."""
|
||||
...
|
||||
|
||||
def unload_model(self) -> None:
|
||||
"""Unload model to free memory."""
|
||||
...
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if model is loaded."""
|
||||
...
|
||||
|
||||
def _get_model_path(self, model_size: str) -> str:
|
||||
"""Get model path for a given size."""
|
||||
...
|
||||
|
||||
async def health(self) -> ProviderHealth:
|
||||
"""Get provider health status."""
|
||||
...
|
||||
|
||||
async def status(self) -> ProviderStatus:
|
||||
"""Get provider model status."""
|
||||
...
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Bundled provider that wraps existing MLX/PyTorch backends.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
import numpy as np
|
||||
import platform
|
||||
|
||||
from .base import TTSProvider
|
||||
from .types import ProviderHealth, ProviderStatus
|
||||
from ..backends import get_tts_backend, TTSBackend
|
||||
from ..platform_detect import get_backend_type
|
||||
|
||||
|
||||
class BundledProvider:
|
||||
"""Provider that wraps the existing bundled TTS backend."""
|
||||
|
||||
def __init__(self):
|
||||
self._backend: Optional[TTSBackend] = None
|
||||
|
||||
def _get_backend(self) -> TTSBackend:
|
||||
"""Get or create backend instance."""
|
||||
if self._backend is None:
|
||||
self._backend = get_tts_backend()
|
||||
return self._backend
|
||||
|
||||
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 speech audio."""
|
||||
backend = self._get_backend()
|
||||
return await backend.generate(text, voice_prompt, language, seed, instruct)
|
||||
|
||||
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."""
|
||||
backend = self._get_backend()
|
||||
return await backend.create_voice_prompt(audio_path, reference_text, use_cache)
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: list[str],
|
||||
reference_texts: list[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
"""Combine multiple voice prompts."""
|
||||
backend = self._get_backend()
|
||||
return await backend.combine_voice_prompts(audio_paths, reference_texts)
|
||||
|
||||
async def load_model_async(self, model_size: str) -> None:
|
||||
"""Load TTS model."""
|
||||
backend = self._get_backend()
|
||||
if hasattr(backend, 'load_model_async'):
|
||||
await backend.load_model_async(model_size)
|
||||
else:
|
||||
await backend.load_model(model_size)
|
||||
|
||||
# Alias for compatibility
|
||||
load_model = load_model_async
|
||||
|
||||
def unload_model(self) -> None:
|
||||
"""Unload model to free memory."""
|
||||
backend = self._get_backend()
|
||||
backend.unload_model()
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if model is loaded."""
|
||||
backend = self._get_backend()
|
||||
return backend.is_loaded()
|
||||
|
||||
def _get_model_path(self, model_size: str) -> str:
|
||||
"""Get model path for a given size."""
|
||||
backend = self._get_backend()
|
||||
return backend._get_model_path(model_size)
|
||||
|
||||
async def health(self) -> ProviderHealth:
|
||||
"""Get provider health status."""
|
||||
backend = self._get_backend()
|
||||
backend_type = get_backend_type()
|
||||
|
||||
model_size = None
|
||||
if backend.is_loaded():
|
||||
# Try to get current model size from backend
|
||||
if hasattr(backend, '_current_model_size') and backend._current_model_size:
|
||||
model_size = backend._current_model_size
|
||||
|
||||
device = None
|
||||
if backend_type == "mlx":
|
||||
device = "metal"
|
||||
elif hasattr(backend, 'device'):
|
||||
device = backend.device
|
||||
|
||||
# Use apple-mlx for MLX backend, pytorch-cpu for PyTorch
|
||||
provider_name = "apple-mlx" if backend_type == "mlx" else "pytorch-cpu"
|
||||
|
||||
return ProviderHealth(
|
||||
status="healthy",
|
||||
provider=provider_name,
|
||||
version=None, # Provider versioning not implemented yet
|
||||
model=model_size,
|
||||
device=device,
|
||||
)
|
||||
|
||||
async def status(self) -> ProviderStatus:
|
||||
"""Get provider model status."""
|
||||
backend = self._get_backend()
|
||||
backend_type = get_backend_type()
|
||||
|
||||
model_size = None
|
||||
if backend.is_loaded():
|
||||
if hasattr(backend, '_current_model_size') and backend._current_model_size:
|
||||
model_size = backend._current_model_size
|
||||
|
||||
available_sizes = ["1.7B"]
|
||||
if backend_type == "pytorch":
|
||||
available_sizes.append("0.6B")
|
||||
|
||||
gpu_available = None
|
||||
vram_used_mb = None
|
||||
|
||||
if backend_type == "pytorch":
|
||||
try:
|
||||
import torch
|
||||
gpu_available = torch.cuda.is_available()
|
||||
if gpu_available:
|
||||
vram_used_mb = torch.cuda.memory_allocated() / 1024 / 1024
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return ProviderStatus(
|
||||
model_loaded=backend.is_loaded(),
|
||||
model_size=model_size,
|
||||
available_sizes=available_sizes,
|
||||
gpu_available=gpu_available,
|
||||
vram_used_mb=int(vram_used_mb) if vram_used_mb else None,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
# Provider checksums - embedded at build time for security
|
||||
# This file is auto-generated during CI builds
|
||||
# In development, checksums are empty (verification is skipped)
|
||||
|
||||
PROVIDER_CHECKSUMS = {
|
||||
# Populated during release builds with SHA256 checksums of provider binaries
|
||||
# Example:
|
||||
# "tts-provider-pytorch-cpu-windows.exe": "abc123...",
|
||||
# "tts-provider-pytorch-cuda-windows.exe": "def456...",
|
||||
# "tts-provider-pytorch-cuda-linux": "789xyz...",
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
Provider download and installation manager.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .types import ProviderType
|
||||
from ..utils.progress import get_progress_manager
|
||||
from ..utils.tasks import get_task_manager
|
||||
|
||||
|
||||
# Provider version (independent of app version)
|
||||
PROVIDER_VERSION = "1.0.0"
|
||||
|
||||
# Base URL for provider downloads (Cloudflare R2)
|
||||
PROVIDER_DOWNLOAD_BASE_URL = "https://downloads.voicebox.sh/providers"
|
||||
|
||||
|
||||
def _get_providers_dir() -> Path:
|
||||
"""Get the directory where providers are stored."""
|
||||
system = platform.system()
|
||||
|
||||
if system == "Windows":
|
||||
appdata = Path.home() / "AppData" / "Roaming"
|
||||
elif system == "Darwin":
|
||||
appdata = Path.home() / "Library" / "Application Support"
|
||||
else: # Linux
|
||||
appdata = Path.home() / ".local" / "share"
|
||||
|
||||
providers_dir = appdata / "voicebox" / "providers"
|
||||
providers_dir.mkdir(parents=True, exist_ok=True)
|
||||
return providers_dir
|
||||
|
||||
|
||||
def _get_provider_binary_name(provider_type: str) -> str:
|
||||
"""Get the local binary filename for a provider type."""
|
||||
system = platform.system()
|
||||
ext = ".exe" if system == "Windows" else ""
|
||||
|
||||
binary_map = {
|
||||
"pytorch-cpu": f"tts-provider-pytorch-cpu{ext}",
|
||||
"pytorch-cuda": f"tts-provider-pytorch-cuda{ext}",
|
||||
}
|
||||
|
||||
if provider_type not in binary_map:
|
||||
raise ValueError(f"Unknown provider type: {provider_type}")
|
||||
|
||||
return binary_map[provider_type]
|
||||
|
||||
|
||||
def _get_provider_download_name(provider_type: str) -> str:
|
||||
"""Get the remote download filename for a provider type (includes platform suffix)."""
|
||||
system = platform.system()
|
||||
|
||||
if system == "Windows":
|
||||
platform_suffix = "windows"
|
||||
ext = ".zip"
|
||||
elif system == "Linux":
|
||||
platform_suffix = "linux"
|
||||
ext = ".tar.gz"
|
||||
elif system == "Darwin":
|
||||
# Detect macOS architecture
|
||||
machine = platform.machine()
|
||||
if machine == "arm64":
|
||||
platform_suffix = "macos-arm64"
|
||||
else:
|
||||
platform_suffix = "macos-x64"
|
||||
ext = ".tar.gz"
|
||||
else:
|
||||
raise ValueError(f"Provider downloads not supported on {system}")
|
||||
|
||||
return f"tts-provider-{provider_type}-{platform_suffix}{ext}"
|
||||
|
||||
|
||||
def _get_provider_download_url(provider_type: str) -> str:
|
||||
"""Get the download URL for a provider."""
|
||||
download_name = _get_provider_download_name(provider_type)
|
||||
return f"{PROVIDER_DOWNLOAD_BASE_URL}/v{PROVIDER_VERSION}/{download_name}"
|
||||
|
||||
|
||||
async def download_provider(provider_type: str) -> Path:
|
||||
"""
|
||||
Download and extract a provider archive from Cloudflare R2.
|
||||
|
||||
Args:
|
||||
provider_type: Type of provider to download (e.g., "pytorch-cpu")
|
||||
|
||||
Returns:
|
||||
Path to the extracted provider binary
|
||||
|
||||
Raises:
|
||||
ValueError: If provider_type is invalid
|
||||
httpx.HTTPError: If download fails
|
||||
"""
|
||||
if provider_type not in ["pytorch-cpu", "pytorch-cuda"]:
|
||||
raise ValueError(f"Provider type {provider_type} cannot be downloaded")
|
||||
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
|
||||
archive_name = _get_provider_download_name(provider_type)
|
||||
download_url = _get_provider_download_url(provider_type)
|
||||
providers_dir = _get_providers_dir()
|
||||
archive_path = providers_dir / archive_name
|
||||
|
||||
# Start tracking download
|
||||
task_manager.start_download(provider_type)
|
||||
|
||||
# Initialize progress state
|
||||
progress_manager.update_progress(
|
||||
model_name=provider_type,
|
||||
current=0,
|
||||
total=0, # Will be updated once we get Content-Length
|
||||
filename=archive_name,
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
try:
|
||||
# Download archive
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
async with client.stream("GET", download_url) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
# Get total size from Content-Length header
|
||||
total_size = int(response.headers.get("Content-Length", 0))
|
||||
|
||||
if total_size > 0:
|
||||
progress_manager.update_progress(
|
||||
model_name=provider_type,
|
||||
current=0,
|
||||
total=total_size,
|
||||
filename=archive_name,
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Download with progress tracking
|
||||
downloaded = 0
|
||||
with open(archive_path, "wb") as f:
|
||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
# Update progress
|
||||
progress_manager.update_progress(
|
||||
model_name=provider_type,
|
||||
current=downloaded,
|
||||
total=total_size if total_size > 0 else downloaded,
|
||||
filename=archive_name,
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Extract archive
|
||||
progress_manager.update_progress(
|
||||
model_name=provider_type,
|
||||
current=downloaded,
|
||||
total=downloaded,
|
||||
filename="Extracting...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
import zipfile
|
||||
import tarfile
|
||||
|
||||
if archive_name.endswith('.zip'):
|
||||
with zipfile.ZipFile(archive_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(providers_dir)
|
||||
elif archive_name.endswith('.tar.gz'):
|
||||
with tarfile.open(archive_path, 'r:gz') as tar_ref:
|
||||
tar_ref.extractall(providers_dir)
|
||||
else:
|
||||
raise ValueError(f"Unsupported archive format: {archive_name}")
|
||||
|
||||
# Remove archive after extraction
|
||||
archive_path.unlink()
|
||||
|
||||
# Get path to extracted binary
|
||||
binary_path = get_provider_binary_path(provider_type)
|
||||
if not binary_path:
|
||||
raise ValueError(f"Provider binary not found after extraction")
|
||||
|
||||
# Make executable on Unix systems
|
||||
if platform.system() != "Windows":
|
||||
binary_path.chmod(0o755)
|
||||
|
||||
# Mark as complete
|
||||
progress_manager.update_progress(
|
||||
model_name=provider_type,
|
||||
current=downloaded,
|
||||
total=downloaded,
|
||||
filename=_get_provider_binary_name(provider_type),
|
||||
status="complete",
|
||||
)
|
||||
task_manager.complete_download(provider_type)
|
||||
|
||||
return binary_path
|
||||
|
||||
except Exception as e:
|
||||
# Clean up archive if it exists
|
||||
if archive_path.exists():
|
||||
archive_path.unlink()
|
||||
|
||||
# Mark as error
|
||||
progress_manager.update_progress(
|
||||
model_name=provider_type,
|
||||
current=0,
|
||||
total=0,
|
||||
filename=archive_name,
|
||||
status="error",
|
||||
)
|
||||
task_manager.error_download(provider_type, str(e))
|
||||
raise
|
||||
|
||||
|
||||
def get_provider_binary_path(provider_type: str) -> Optional[Path]:
|
||||
"""
|
||||
Get the path to an installed provider binary.
|
||||
|
||||
Args:
|
||||
provider_type: Type of provider
|
||||
|
||||
Returns:
|
||||
Path to provider binary, or None if not installed
|
||||
"""
|
||||
providers_dir = _get_providers_dir()
|
||||
binary_name = _get_provider_binary_name(provider_type)
|
||||
|
||||
# Check for --onedir structure (directory with binary inside)
|
||||
provider_dir = providers_dir / f"tts-provider-{provider_type}"
|
||||
if provider_dir.exists() and provider_dir.is_dir():
|
||||
binary_path = provider_dir / binary_name
|
||||
if binary_path.exists() and binary_path.is_file():
|
||||
return binary_path
|
||||
|
||||
# Fallback: check for direct binary (legacy)
|
||||
provider_path = providers_dir / binary_name
|
||||
if provider_path.exists() and provider_path.is_file():
|
||||
return provider_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def delete_provider(provider_type: str) -> bool:
|
||||
"""
|
||||
Delete an installed provider binary.
|
||||
|
||||
Args:
|
||||
provider_type: Type of provider to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
provider_path = get_provider_binary_path(provider_type)
|
||||
|
||||
if provider_path and provider_path.exists():
|
||||
provider_path.unlink()
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Local provider that communicates with standalone provider servers via HTTP.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
import base64
|
||||
import io
|
||||
import numpy as np
|
||||
import httpx
|
||||
import soundfile as sf
|
||||
|
||||
from .base import TTSProvider
|
||||
from .types import ProviderHealth, ProviderStatus
|
||||
|
||||
|
||||
class LocalProvider:
|
||||
"""Provider that communicates with local subprocess via HTTP."""
|
||||
|
||||
def __init__(self, base_url: str):
|
||||
"""
|
||||
Initialize local provider.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the provider server (e.g., "http://localhost:8000")
|
||||
"""
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.client = httpx.AsyncClient(timeout=300.0) # 5 minute timeout for generation
|
||||
self._current_model_size = "1.7B" # Default model size
|
||||
|
||||
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 speech audio."""
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/tts/generate",
|
||||
json={
|
||||
"text": text,
|
||||
"voice_prompt": voice_prompt,
|
||||
"language": language,
|
||||
"seed": seed,
|
||||
"model_size": self._current_model_size,
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Decode base64 audio
|
||||
audio_bytes = base64.b64decode(data["audio"])
|
||||
audio_buffer = io.BytesIO(audio_bytes)
|
||||
audio, sample_rate = sf.read(audio_buffer)
|
||||
|
||||
return audio, data["sample_rate"]
|
||||
|
||||
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."""
|
||||
# Read audio file
|
||||
with open(audio_path, 'rb') as f:
|
||||
audio_data = f.read()
|
||||
|
||||
# Send multipart form data
|
||||
files = {
|
||||
"audio": ("audio.wav", audio_data, "audio/wav")
|
||||
}
|
||||
data = {
|
||||
"reference_text": reference_text,
|
||||
"use_cache": str(use_cache).lower(),
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/tts/create_voice_prompt",
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
return result["voice_prompt"], result.get("was_cached", False)
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: list[str],
|
||||
reference_texts: list[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
"""
|
||||
Combine multiple voice prompts.
|
||||
|
||||
Note: This is not implemented in the provider API yet.
|
||||
For now, we'll combine locally by concatenating audio.
|
||||
"""
|
||||
import numpy as np
|
||||
from ..utils.audio import load_audio, normalize_audio
|
||||
|
||||
combined_audio = []
|
||||
for audio_path in audio_paths:
|
||||
audio, sr = load_audio(audio_path)
|
||||
audio = normalize_audio(audio)
|
||||
combined_audio.append(audio)
|
||||
|
||||
# Concatenate audio
|
||||
mixed = np.concatenate(combined_audio)
|
||||
mixed = normalize_audio(mixed)
|
||||
|
||||
# Combine texts
|
||||
combined_text = " ".join(reference_texts)
|
||||
|
||||
return mixed, combined_text
|
||||
|
||||
async def load_model_async(self, model_size: str) -> None:
|
||||
"""Load TTS model."""
|
||||
# Track the requested model size - the provider server will load it
|
||||
# when generate() is called with this size
|
||||
self._current_model_size = model_size
|
||||
|
||||
# Alias for compatibility
|
||||
load_model = load_model_async
|
||||
|
||||
def unload_model(self) -> None:
|
||||
"""Unload model to free memory."""
|
||||
# Model unloading is handled by the provider server
|
||||
# This is a no-op for local providers
|
||||
pass
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if model is loaded."""
|
||||
# We can't know this without querying the provider
|
||||
# Return True optimistically
|
||||
return True
|
||||
|
||||
def _get_model_path(self, model_size: str) -> str:
|
||||
"""Get model path for a given size."""
|
||||
# For local providers, model paths are handled by the provider server
|
||||
# Return a placeholder
|
||||
return f"Qwen/Qwen3-TTS-12Hz-{model_size}-Base"
|
||||
|
||||
async def health(self) -> ProviderHealth:
|
||||
"""Get provider health status."""
|
||||
try:
|
||||
response = await self.client.get(f"{self.base_url}/tts/health")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return ProviderHealth(
|
||||
status=data["status"],
|
||||
provider=data["provider"],
|
||||
version=data.get("version"),
|
||||
model=data.get("model"),
|
||||
device=data.get("device"),
|
||||
)
|
||||
except Exception as e:
|
||||
return ProviderHealth(
|
||||
status="unhealthy",
|
||||
provider="local",
|
||||
version=None,
|
||||
model=None,
|
||||
device=None,
|
||||
)
|
||||
|
||||
async def status(self) -> ProviderStatus:
|
||||
"""Get provider model status."""
|
||||
try:
|
||||
response = await self.client.get(f"{self.base_url}/tts/status")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return ProviderStatus(
|
||||
model_loaded=data["model_loaded"],
|
||||
model_size=data.get("model_size"),
|
||||
available_sizes=data.get("available_sizes", []),
|
||||
gpu_available=data.get("gpu_available"),
|
||||
vram_used_mb=data.get("vram_used_mb"),
|
||||
)
|
||||
except Exception as e:
|
||||
return ProviderStatus(
|
||||
model_loaded=False,
|
||||
model_size=None,
|
||||
available_sizes=[],
|
||||
gpu_available=None,
|
||||
vram_used_mb=None,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the provider (close HTTP client)."""
|
||||
await self.client.aclose()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Shared types for TTS providers.
|
||||
"""
|
||||
|
||||
from typing import Optional, TypedDict
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ProviderType(str, Enum):
|
||||
"""Available provider types."""
|
||||
BUNDLED_MLX = "apple-mlx"
|
||||
BUNDLED_PYTORCH = "bundled-pytorch"
|
||||
PYTORCH_CPU = "pytorch-cpu"
|
||||
PYTORCH_CUDA = "pytorch-cuda"
|
||||
REMOTE = "remote"
|
||||
OPENAI = "openai"
|
||||
|
||||
|
||||
class ProviderHealth(TypedDict):
|
||||
"""Provider health status."""
|
||||
status: str # "healthy", "unhealthy", "starting"
|
||||
provider: str
|
||||
version: Optional[str]
|
||||
model: Optional[str]
|
||||
device: Optional[str]
|
||||
|
||||
|
||||
class ProviderStatus(TypedDict):
|
||||
"""Provider model status."""
|
||||
model_loaded: bool
|
||||
model_size: Optional[str]
|
||||
available_sizes: list[str]
|
||||
gpu_available: Optional[bool]
|
||||
vram_used_mb: Optional[int]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Backend Tests
|
||||
|
||||
Manual test scripts for debugging and validating backend functionality.
|
||||
|
||||
## Test Files
|
||||
|
||||
### `test_generation_progress.py`
|
||||
Tests TTS generation with SSE progress monitoring to identify UX issues where users see download progress even when the model is already cached.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend
|
||||
python tests/test_generation_progress.py
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Server must be running (`python main.py`)
|
||||
- At least one voice profile must exist
|
||||
|
||||
### `test_real_download.py`
|
||||
Tests real model download with SSE progress monitoring.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend
|
||||
# Delete cache first to force fresh download
|
||||
rm -rf ~/.cache/huggingface/hub/models--openai--whisper-base
|
||||
python tests/test_real_download.py
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Server must be running (`python main.py`)
|
||||
|
||||
### `test_progress.py`
|
||||
Unit tests for ProgressManager and HFProgressTracker functionality.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend
|
||||
python tests/test_progress.py
|
||||
```
|
||||
|
||||
### `test_check_progress_state.py`
|
||||
Debugging script to inspect the internal state of ProgressManager and TaskManager.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend
|
||||
python tests/test_check_progress_state.py
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
These are manual test scripts, not automated unit tests. They're designed for:
|
||||
- Debugging progress tracking issues
|
||||
- Validating SSE event streams
|
||||
- Monitoring real-time download behavior
|
||||
- Inspecting internal state during development
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Test suite for Voicebox backend.
|
||||
|
||||
This directory contains manual test scripts for debugging and validating
|
||||
progress tracking, model downloads, and generation functionality.
|
||||
"""
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
Test TTS generation with SSE progress monitoring.
|
||||
This test captures the exact SSE events triggered during generation
|
||||
to identify UX issues where users see download progress even when
|
||||
the model is already cached.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 120):
|
||||
"""Monitor SSE stream for a model during generation."""
|
||||
events: List[Dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
|
||||
print(f"[{_timestamp()}] Connecting to SSE endpoint: {url}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
print(f"[{_timestamp()}] SSE connected, status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"[{_timestamp()}] Error: SSE endpoint returned {response.status_code}")
|
||||
return events
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
timestamp = _timestamp()
|
||||
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
print(f"[{timestamp}] → SSE Event: {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
|
||||
events.append({
|
||||
**data,
|
||||
"_timestamp": timestamp
|
||||
})
|
||||
|
||||
# Stop if complete or error
|
||||
if data.get("status") in ("complete", "error"):
|
||||
print(f"[{timestamp}] → Model {data['status']}!")
|
||||
break
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[{timestamp}] Error parsing JSON: {e}")
|
||||
print(f" Line was: {line}")
|
||||
|
||||
elif line.startswith(": heartbeat"):
|
||||
print(f"[{timestamp}] ♥ heartbeat")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
print(f"[{_timestamp()}] SSE monitoring timed out")
|
||||
except Exception as e:
|
||||
print(f"[{_timestamp()}] SSE error: {e}")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def trigger_generation(profile_id: str, text: str, model_size: str = "1.7B"):
|
||||
"""Trigger TTS generation via the API."""
|
||||
url = "http://localhost:8000/generate"
|
||||
|
||||
print(f"\n[{_timestamp()}] Triggering generation...")
|
||||
print(f" Profile: {profile_id}")
|
||||
print(f" Text: {text[:50]}...")
|
||||
print(f" Model: {model_size}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(url, json={
|
||||
"profile_id": profile_id,
|
||||
"text": text,
|
||||
"language": "en",
|
||||
"model_size": model_size,
|
||||
})
|
||||
|
||||
print(f"[{_timestamp()}] Response: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print(f"[{_timestamp()}] ✓ Generation successful!")
|
||||
print(f" Generation ID: {result.get('id')}")
|
||||
print(f" Duration: {result.get('duration', 0):.2f}s")
|
||||
return True, result
|
||||
elif response.status_code == 202:
|
||||
# Model is being downloaded
|
||||
result = response.json()
|
||||
print(f"[{_timestamp()}] → Model download in progress")
|
||||
print(f" Detail: {result}")
|
||||
return False, result
|
||||
else:
|
||||
print(f"[{_timestamp()}] ✗ Error: {response.text}")
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[{_timestamp()}] ✗ Exception: {e}")
|
||||
return False, None
|
||||
|
||||
|
||||
async def get_first_profile():
|
||||
"""Get the first available voice profile."""
|
||||
url = "http://localhost:8000/profiles"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(url)
|
||||
if response.status_code == 200:
|
||||
profiles = response.json()
|
||||
if profiles:
|
||||
return profiles[0]["id"]
|
||||
except Exception as e:
|
||||
print(f"Error getting profiles: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def check_server():
|
||||
"""Check if the server is running."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get("http://localhost:8000/health")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
print(f"Server not running: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _timestamp():
|
||||
"""Get current timestamp for logging."""
|
||||
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
|
||||
|
||||
async def test_generation_with_cached_model():
|
||||
"""
|
||||
Test Case 1: Generation when model is already cached.
|
||||
|
||||
This should NOT show any download progress events.
|
||||
If it does, that's the UX bug we're trying to fix.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("TEST CASE 1: Generation with Cached Model")
|
||||
print("=" * 80)
|
||||
print("Expected: No download progress events (or minimal/instant completion)")
|
||||
print("Actual UX Issue: Users see 'started' and 'finished' events even for cached models")
|
||||
print("=" * 80)
|
||||
|
||||
model_size = "1.7B"
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
# Get a profile
|
||||
profile_id = await get_first_profile()
|
||||
if not profile_id:
|
||||
print("✗ No voice profiles found. Please create a profile first.")
|
||||
return False
|
||||
|
||||
print(f"\nUsing profile: {profile_id}")
|
||||
|
||||
# Start SSE monitor BEFORE triggering generation
|
||||
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=30))
|
||||
|
||||
# Wait for SSE to connect
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Trigger generation
|
||||
test_text = "Hello, this is a test of the voice generation system."
|
||||
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||
|
||||
if not success and result and result.get("downloading"):
|
||||
print("\n⚠ Model is being downloaded. Waiting for download to complete...")
|
||||
# Wait for SSE monitor to capture download events
|
||||
events = await monitor_task
|
||||
return events
|
||||
|
||||
# Wait a bit more to catch any progress events
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Cancel SSE monitor
|
||||
monitor_task.cancel()
|
||||
try:
|
||||
events = await monitor_task
|
||||
except asyncio.CancelledError:
|
||||
events = []
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def test_generation_with_fresh_download():
|
||||
"""
|
||||
Test Case 2: Generation when model needs to be downloaded.
|
||||
|
||||
This SHOULD show download progress events.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("TEST CASE 2: Generation with Model Download")
|
||||
print("=" * 80)
|
||||
print("Expected: Download progress events from 0% to 100%")
|
||||
print("=" * 80)
|
||||
|
||||
# Use a different model size to force download
|
||||
model_size = "0.6B" # Smaller model for faster testing
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
# Get a profile
|
||||
profile_id = await get_first_profile()
|
||||
if not profile_id:
|
||||
print("✗ No voice profiles found. Please create a profile first.")
|
||||
return False
|
||||
|
||||
print(f"\nUsing profile: {profile_id}")
|
||||
print("Note: This will download the model if not cached")
|
||||
|
||||
# Start SSE monitor BEFORE triggering generation
|
||||
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=300))
|
||||
|
||||
# Wait for SSE to connect
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Trigger generation
|
||||
test_text = "This should trigger a model download if the model is not cached."
|
||||
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||
|
||||
if not success and result and result.get("downloading"):
|
||||
print("\n→ Model download initiated. Monitoring progress...")
|
||||
# Wait for download to complete
|
||||
events = await monitor_task
|
||||
|
||||
# Try generation again
|
||||
print(f"\n[{_timestamp()}] Retrying generation after download...")
|
||||
await asyncio.sleep(2)
|
||||
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||
|
||||
if success:
|
||||
print("✓ Generation successful after download")
|
||||
|
||||
return events
|
||||
|
||||
# If model was already cached
|
||||
await asyncio.sleep(3)
|
||||
monitor_task.cancel()
|
||||
try:
|
||||
events = await monitor_task
|
||||
except asyncio.CancelledError:
|
||||
events = []
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 80)
|
||||
print("TTS Generation Progress Test")
|
||||
print("=" * 80)
|
||||
print("Purpose: Capture exact SSE events during generation to identify UX issues")
|
||||
print("=" * 80)
|
||||
|
||||
# Check if server is running
|
||||
print(f"\n[{_timestamp()}] Checking if server is running...")
|
||||
if not await check_server():
|
||||
print("✗ Server is not running on http://localhost:8000")
|
||||
print("\nPlease start the server first:")
|
||||
print(" cd backend && python main.py")
|
||||
return False
|
||||
|
||||
print("✓ Server is running")
|
||||
|
||||
# Test Case 1: Cached model
|
||||
print("\n" + "🧪 " * 20)
|
||||
events_cached = await test_generation_with_cached_model()
|
||||
|
||||
# Results for Test Case 1
|
||||
print("\n" + "=" * 80)
|
||||
print("TEST CASE 1 RESULTS: Generation with Cached Model")
|
||||
print("=" * 80)
|
||||
|
||||
if not events_cached:
|
||||
print("✓ GOOD: No SSE progress events received")
|
||||
print(" This is the expected behavior for a cached model.")
|
||||
else:
|
||||
print(f"⚠ ISSUE FOUND: Received {len(events_cached)} SSE events:")
|
||||
print("\nEvent Timeline:")
|
||||
for i, event in enumerate(events_cached, 1):
|
||||
timestamp = event.pop("_timestamp", "??:??:??.???")
|
||||
print(f" {i}. [{timestamp}] {event}")
|
||||
|
||||
print("\n⚠ This explains the UX issue!")
|
||||
print(" Users see progress events even when the model is already cached,")
|
||||
print(" making them think the model is downloading again.")
|
||||
|
||||
# Test Case 2: Fresh download (optional, commented out by default)
|
||||
# Uncomment if you want to test download progress
|
||||
# print("\n" + "🧪 " * 20)
|
||||
# events_download = await test_generation_with_fresh_download()
|
||||
#
|
||||
# print("\n" + "=" * 80)
|
||||
# print("TEST CASE 2 RESULTS: Generation with Model Download")
|
||||
# print("=" * 80)
|
||||
#
|
||||
# if not events_download:
|
||||
# print("ℹ Model was already cached, no download occurred")
|
||||
# else:
|
||||
# print(f"✓ Received {len(events_download)} download progress events")
|
||||
# print("\nDownload Timeline:")
|
||||
# for i, event in enumerate(events_download, 1):
|
||||
# timestamp = event.pop("_timestamp", "??:??:??.???")
|
||||
# print(f" {i}. [{timestamp}] {event}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("Test Complete!")
|
||||
print("=" * 80)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Test script to debug model download progress tracking.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import List, Dict
|
||||
import logging
|
||||
|
||||
# Set up logging to see what's happening
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
from utils.progress import ProgressManager, get_progress_manager
|
||||
from utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||
|
||||
|
||||
def test_progress_manager_basic():
|
||||
"""Test 1: Basic ProgressManager functionality."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 1: ProgressManager Basic Operations")
|
||||
print("=" * 60)
|
||||
|
||||
pm = ProgressManager()
|
||||
|
||||
# Test update_progress
|
||||
pm.update_progress(
|
||||
model_name="test-model",
|
||||
current=50,
|
||||
total=100,
|
||||
filename="test.bin",
|
||||
status="downloading"
|
||||
)
|
||||
|
||||
# Test get_progress
|
||||
progress = pm.get_progress("test-model")
|
||||
print(f"✓ Progress stored: {progress}")
|
||||
assert progress is not None
|
||||
assert progress["progress"] == 50.0
|
||||
assert progress["filename"] == "test.bin"
|
||||
assert progress["status"] == "downloading"
|
||||
|
||||
# Test mark_complete
|
||||
pm.mark_complete("test-model")
|
||||
progress = pm.get_progress("test-model")
|
||||
print(f"✓ Marked complete: {progress}")
|
||||
assert progress["status"] == "complete"
|
||||
assert progress["progress"] == 100.0
|
||||
|
||||
print("✓ Test 1 PASSED\n")
|
||||
return True
|
||||
|
||||
|
||||
async def test_progress_manager_sse():
|
||||
"""Test 2: ProgressManager SSE streaming."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 2: ProgressManager SSE Streaming")
|
||||
print("=" * 60)
|
||||
|
||||
pm = ProgressManager()
|
||||
collected_events: List[Dict] = []
|
||||
|
||||
# Simulate SSE client
|
||||
async def sse_client():
|
||||
"""Simulates a frontend SSE connection."""
|
||||
print(" SSE client: Subscribing to test-model-sse...")
|
||||
async for event in pm.subscribe("test-model-sse"):
|
||||
# Parse SSE event
|
||||
if event.startswith("data: "):
|
||||
data = json.loads(event[6:])
|
||||
print(f" SSE client: Received event: {data['status']} - {data.get('progress', 0):.1f}%")
|
||||
collected_events.append(data)
|
||||
|
||||
# Stop when complete
|
||||
if data.get("status") in ("complete", "error"):
|
||||
break
|
||||
elif event.startswith(": heartbeat"):
|
||||
print(" SSE client: Received heartbeat")
|
||||
|
||||
# Simulate download progress updates (from backend thread)
|
||||
async def simulate_download():
|
||||
"""Simulates backend sending progress updates."""
|
||||
print(" Backend: Starting simulated download...")
|
||||
await asyncio.sleep(0.2) # Let SSE client subscribe first
|
||||
|
||||
# Send progress updates
|
||||
for i in range(0, 101, 20):
|
||||
print(f" Backend: Updating progress to {i}%")
|
||||
pm.update_progress(
|
||||
model_name="test-model-sse",
|
||||
current=i,
|
||||
total=100,
|
||||
filename=f"file_{i}.bin",
|
||||
status="downloading" if i < 100 else "downloading"
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Mark complete
|
||||
print(" Backend: Marking download complete")
|
||||
pm.mark_complete("test-model-sse")
|
||||
|
||||
# Run SSE client and download simulation concurrently
|
||||
await asyncio.gather(
|
||||
sse_client(),
|
||||
simulate_download()
|
||||
)
|
||||
|
||||
# Verify we got events
|
||||
print(f"\n Collected {len(collected_events)} events")
|
||||
assert len(collected_events) > 0, "Should have received at least one event"
|
||||
assert collected_events[-1]["status"] == "complete", "Last event should be 'complete'"
|
||||
|
||||
print("✓ Test 2 PASSED\n")
|
||||
return True
|
||||
|
||||
|
||||
def test_hf_progress_tracker():
|
||||
"""Test 3: HFProgressTracker tqdm patching."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 3: HFProgressTracker tqdm Patching")
|
||||
print("=" * 60)
|
||||
|
||||
captured_progress: List[tuple] = []
|
||||
|
||||
def progress_callback(downloaded: int, total: int, filename: str):
|
||||
"""Capture progress updates."""
|
||||
captured_progress.append((downloaded, total, filename))
|
||||
print(f" Progress callback: {downloaded}/{total} bytes ({filename})")
|
||||
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
|
||||
# Simulate a download with tqdm
|
||||
with tracker.patch_download():
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
|
||||
# Simulate downloading a file
|
||||
print(" Simulating download with tqdm...")
|
||||
total_size = 1000
|
||||
with tqdm(total=total_size, desc="model.bin", unit="B", unit_scale=True) as pbar:
|
||||
for chunk in range(0, total_size, 100):
|
||||
pbar.update(100)
|
||||
time.sleep(0.01)
|
||||
|
||||
print(f" Captured {len(captured_progress)} progress updates")
|
||||
assert len(captured_progress) > 0, "Should have captured progress updates"
|
||||
|
||||
# Verify progress increases
|
||||
last_downloaded = 0
|
||||
for downloaded, total, filename in captured_progress:
|
||||
assert downloaded >= last_downloaded, "Downloaded bytes should increase"
|
||||
assert total == total_size, "Total should be consistent"
|
||||
last_downloaded = downloaded
|
||||
|
||||
print("✓ Test 3 PASSED\n")
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
print("✗ tqdm not available, skipping test\n")
|
||||
return None
|
||||
|
||||
|
||||
async def test_full_integration():
|
||||
"""Test 4: Full integration test."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 4: Full Integration (ProgressManager + HFProgressTracker)")
|
||||
print("=" * 60)
|
||||
|
||||
pm = get_progress_manager()
|
||||
collected_events: List[Dict] = []
|
||||
|
||||
# SSE client
|
||||
async def sse_client():
|
||||
print(" SSE client: Subscribing...")
|
||||
async for event in pm.subscribe("integration-test"):
|
||||
if event.startswith("data: "):
|
||||
data = json.loads(event[6:])
|
||||
print(f" SSE client: {data['status']} - {data.get('progress', 0):.1f}% - {data.get('filename', '')}")
|
||||
collected_events.append(data)
|
||||
if data.get("status") in ("complete", "error"):
|
||||
break
|
||||
|
||||
# Simulate backend download with HFProgressTracker
|
||||
async def simulate_real_download():
|
||||
await asyncio.sleep(0.2) # Let SSE subscribe
|
||||
|
||||
print(" Backend: Starting download with HFProgressTracker...")
|
||||
|
||||
# Set up tracking (like the real backend does)
|
||||
progress_callback = create_hf_progress_callback("integration-test", pm)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
|
||||
# Initialize progress
|
||||
pm.update_progress(
|
||||
model_name="integration-test",
|
||||
current=0,
|
||||
total=1,
|
||||
filename="",
|
||||
status="downloading"
|
||||
)
|
||||
|
||||
# Simulate download with tqdm patching
|
||||
with tracker.patch_download():
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
|
||||
# Simulate multi-file download (like HuggingFace does)
|
||||
files = [
|
||||
("model.safetensors", 5000),
|
||||
("config.json", 1000),
|
||||
("tokenizer.json", 500),
|
||||
]
|
||||
|
||||
for filename, size in files:
|
||||
print(f" Backend: Downloading {filename}...")
|
||||
with tqdm(total=size, desc=filename, unit="B") as pbar:
|
||||
for chunk in range(0, size, 500):
|
||||
chunk_size = min(500, size - chunk)
|
||||
pbar.update(chunk_size)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# Mark complete
|
||||
print(" Backend: Download complete")
|
||||
pm.mark_complete("integration-test")
|
||||
|
||||
except ImportError:
|
||||
print(" ✗ tqdm not available")
|
||||
pm.mark_error("integration-test", "tqdm not available")
|
||||
|
||||
# Run both
|
||||
await asyncio.gather(
|
||||
sse_client(),
|
||||
simulate_real_download()
|
||||
)
|
||||
|
||||
# Verify
|
||||
print(f"\n Collected {len(collected_events)} events")
|
||||
if len(collected_events) > 0:
|
||||
print(f" First event: {collected_events[0]}")
|
||||
print(f" Last event: {collected_events[-1]}")
|
||||
assert collected_events[-1]["status"] == "complete", "Should end with 'complete'"
|
||||
print("✓ Test 4 PASSED\n")
|
||||
return True
|
||||
else:
|
||||
print("✗ Test 4 FAILED - No events received\n")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all tests."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Voicebox Progress Tracking Test Suite")
|
||||
print("=" * 60)
|
||||
|
||||
results = []
|
||||
|
||||
# Test 1: Basic operations
|
||||
try:
|
||||
results.append(("Basic Operations", test_progress_manager_basic()))
|
||||
except Exception as e:
|
||||
print(f"✗ Test 1 FAILED: {e}\n")
|
||||
results.append(("Basic Operations", False))
|
||||
|
||||
# Test 2: SSE streaming
|
||||
try:
|
||||
results.append(("SSE Streaming", await test_progress_manager_sse()))
|
||||
except Exception as e:
|
||||
print(f"✗ Test 2 FAILED: {e}\n")
|
||||
results.append(("SSE Streaming", False))
|
||||
|
||||
# Test 3: tqdm patching
|
||||
try:
|
||||
results.append(("tqdm Patching", test_hf_progress_tracker()))
|
||||
except Exception as e:
|
||||
print(f"✗ Test 3 FAILED: {e}\n")
|
||||
results.append(("tqdm Patching", False))
|
||||
|
||||
# Test 4: Full integration
|
||||
try:
|
||||
results.append(("Full Integration", await test_full_integration()))
|
||||
except Exception as e:
|
||||
print(f"✗ Test 4 FAILED: {e}\n")
|
||||
results.append(("Full Integration", False))
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("Test Results Summary")
|
||||
print("=" * 60)
|
||||
|
||||
for name, result in results:
|
||||
status = "✓ PASS" if result else ("⊘ SKIP" if result is None else "✗ FAIL")
|
||||
print(f" {status:8} {name}")
|
||||
|
||||
passed = sum(1 for _, r in results if r is True)
|
||||
failed = sum(1 for _, r in results if r is False)
|
||||
skipped = sum(1 for _, r in results if r is None)
|
||||
|
||||
print()
|
||||
print(f" Total: {len(results)} tests")
|
||||
print(f" Passed: {passed}")
|
||||
print(f" Failed: {failed}")
|
||||
print(f" Skipped: {skipped}")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
return failed == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = asyncio.run(main())
|
||||
exit(0 if success else 1)
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Test Qwen TTS model download with SSE progress monitoring.
|
||||
|
||||
This specifically tests the MLX TTS backend download progress tracking,
|
||||
which requires tqdm to be patched BEFORE mlx_audio is imported.
|
||||
|
||||
Usage:
|
||||
cd backend && python -m tests.test_qwen_download
|
||||
|
||||
Prerequisites:
|
||||
- Server must be running: cd backend && python main.py
|
||||
- Delete model first for fresh download test:
|
||||
curl -X DELETE http://localhost:8000/models/qwen-tts-0.6B
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
import time
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 600) -> List[Dict]:
|
||||
"""
|
||||
Monitor SSE stream for a model download.
|
||||
|
||||
Args:
|
||||
model_name: Name of the model to monitor
|
||||
timeout: Maximum time to wait for download (seconds)
|
||||
|
||||
Returns:
|
||||
List of SSE events received
|
||||
"""
|
||||
events: List[Dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
last_progress = -1
|
||||
|
||||
print(f"\n📡 Connecting to SSE endpoint: {url}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
print(f" SSE connected, status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f" ❌ Error: SSE endpoint returned {response.status_code}")
|
||||
return events
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
events.append(data)
|
||||
|
||||
# Print progress (only when it changes significantly)
|
||||
progress = data.get('progress', 0)
|
||||
status = data.get('status', 'unknown')
|
||||
filename = data.get('filename', '')
|
||||
current = data.get('current', 0)
|
||||
total = data.get('total', 0)
|
||||
|
||||
# Print every 5% change or status change
|
||||
if abs(progress - last_progress) >= 5 or status in ('complete', 'error'):
|
||||
current_mb = current / (1024 * 1024)
|
||||
total_mb = total / (1024 * 1024)
|
||||
print(f" 📊 {status:12} {progress:6.1f}% ({current_mb:.1f}MB / {total_mb:.1f}MB) {filename[:50]}")
|
||||
last_progress = progress
|
||||
|
||||
# Stop if complete or error
|
||||
if status in ("complete", "error"):
|
||||
if status == "complete":
|
||||
print(f" ✅ Download complete!")
|
||||
else:
|
||||
print(f" ❌ Download error: {data.get('error', 'unknown')}")
|
||||
break
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" ⚠️ Error parsing JSON: {e}")
|
||||
|
||||
elif line.startswith(": heartbeat"):
|
||||
# Heartbeat every 1 second, don't spam
|
||||
pass
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print(" ⏹️ SSE monitor cancelled")
|
||||
except Exception as e:
|
||||
print(f" ❌ SSE error: {e}")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def trigger_download(model_name: str) -> bool:
|
||||
"""Trigger a model download via the API."""
|
||||
url = "http://localhost:8000/models/download"
|
||||
|
||||
print(f"\n🚀 Triggering download for: {model_name}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(url, json={"model_name": model_name})
|
||||
result = response.json()
|
||||
print(f" Response: {response.status_code} - {result}")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
print(f" ❌ Error triggering download: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def delete_model(model_name: str) -> bool:
|
||||
"""Delete a model from cache."""
|
||||
url = f"http://localhost:8000/models/{model_name}"
|
||||
|
||||
print(f"\n🗑️ Deleting model: {model_name}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.delete(url)
|
||||
if response.status_code == 200:
|
||||
print(f" ✅ Model deleted")
|
||||
return True
|
||||
elif response.status_code == 404:
|
||||
print(f" ℹ️ Model not found (already deleted)")
|
||||
return True
|
||||
else:
|
||||
print(f" ⚠️ Delete response: {response.status_code} - {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ❌ Error deleting model: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def check_model_status(model_name: str) -> Optional[Dict]:
|
||||
"""Check the status of a model."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get("http://localhost:8000/models/status")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
for model in data.get("models", []):
|
||||
if model["model_name"] == model_name:
|
||||
return model
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Error checking model status: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def check_server() -> bool:
|
||||
"""Check if the server is running."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get("http://localhost:8000/health")
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 70)
|
||||
print("🧪 Qwen TTS Model Download Progress Test")
|
||||
print("=" * 70)
|
||||
print("\nThis test verifies that MLX TTS download progress tracking works.")
|
||||
print("It specifically tests the tqdm patching for mlx_audio.tts imports.")
|
||||
|
||||
# Check if server is running
|
||||
print("\n📡 Checking if server is running...")
|
||||
if not await check_server():
|
||||
print(" ❌ Server is not running on http://localhost:8000")
|
||||
print("\n Please start the server first:")
|
||||
print(" cd backend && python main.py")
|
||||
return False
|
||||
|
||||
print(" ✅ Server is running")
|
||||
|
||||
# Test model
|
||||
model_name = "qwen-tts-0.6B" # Note: 0.6B currently maps to 1.7B on MLX
|
||||
|
||||
# Check current status
|
||||
print(f"\n📊 Checking status of {model_name}...")
|
||||
status = await check_model_status(model_name)
|
||||
if status:
|
||||
print(f" Downloaded: {status.get('downloaded', False)}")
|
||||
print(f" Downloading: {status.get('downloading', False)}")
|
||||
print(f" Loaded: {status.get('loaded', False)}")
|
||||
if status.get('size_mb'):
|
||||
print(f" Size: {status['size_mb']:.1f} MB")
|
||||
else:
|
||||
print(" ⚠️ Could not get model status")
|
||||
|
||||
# Ask if user wants to delete first
|
||||
print("\n" + "-" * 70)
|
||||
if status and status.get('downloaded'):
|
||||
print("⚠️ Model is already downloaded. Delete it for a fresh download test?")
|
||||
print(" [y] Yes, delete and download fresh")
|
||||
print(" [n] No, just test SSE connection")
|
||||
print(" [q] Quit")
|
||||
|
||||
choice = input("\nChoice [y/n/q]: ").strip().lower()
|
||||
|
||||
if choice == 'q':
|
||||
print("Exiting...")
|
||||
return True
|
||||
|
||||
if choice == 'y':
|
||||
if not await delete_model(model_name):
|
||||
print("Failed to delete model. Continue anyway? [y/n]")
|
||||
if input().strip().lower() != 'y':
|
||||
return False
|
||||
else:
|
||||
print("Model not downloaded. Will perform fresh download test.")
|
||||
input("Press Enter to continue...")
|
||||
|
||||
# Run the test
|
||||
print("\n" + "=" * 70)
|
||||
print("🏃 Starting Download Test")
|
||||
print("=" * 70)
|
||||
|
||||
async def run_test():
|
||||
# Start SSE monitor in background FIRST
|
||||
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=600))
|
||||
|
||||
# Wait for SSE to connect
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Trigger download
|
||||
success = await trigger_download(model_name)
|
||||
|
||||
if not success:
|
||||
print(" ❌ Failed to trigger download")
|
||||
monitor_task.cancel()
|
||||
try:
|
||||
await monitor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return []
|
||||
|
||||
# Wait for SSE monitor to complete
|
||||
print("\n⏳ Waiting for download to complete (this may take several minutes)...")
|
||||
events = await monitor_task
|
||||
|
||||
return events
|
||||
|
||||
start_time = time.time()
|
||||
events = await run_test()
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Results
|
||||
print("\n" + "=" * 70)
|
||||
print("📋 Test Results")
|
||||
print("=" * 70)
|
||||
|
||||
print(f"\n⏱️ Elapsed time: {elapsed:.1f} seconds")
|
||||
print(f"📨 Total SSE events received: {len(events)}")
|
||||
|
||||
if not events:
|
||||
print("\n❌ FAILED - No SSE events received!")
|
||||
print("\nPossible causes:")
|
||||
print(" 1. SSE endpoint not working")
|
||||
print(" 2. tqdm not patched before mlx_audio import")
|
||||
print(" 3. Progress callbacks not firing")
|
||||
print(" 4. Model already fully downloaded")
|
||||
print("\nDebug steps:")
|
||||
print(" 1. Check server logs for [DEBUG] messages")
|
||||
print(" 2. Look for 'tqdm patched' before 'mlx_audio.tts import'")
|
||||
print(f" 3. Delete model: curl -X DELETE http://localhost:8000/models/{model_name}")
|
||||
return False
|
||||
|
||||
# Analyze events
|
||||
first_event = events[0]
|
||||
last_event = events[-1]
|
||||
|
||||
print(f"\n📊 First event:")
|
||||
print(f" Status: {first_event.get('status')}")
|
||||
print(f" Progress: {first_event.get('progress', 0):.1f}%")
|
||||
|
||||
print(f"\n📊 Last event:")
|
||||
print(f" Status: {last_event.get('status')}")
|
||||
print(f" Progress: {last_event.get('progress', 0):.1f}%")
|
||||
|
||||
# Check for expected behaviors
|
||||
has_progress_updates = len(events) > 2
|
||||
has_increasing_progress = False
|
||||
has_complete = any(e.get('status') == 'complete' for e in events)
|
||||
has_100_percent = any(e.get('progress', 0) >= 100 for e in events)
|
||||
|
||||
# Check if progress increased over time
|
||||
if len(events) >= 2:
|
||||
progress_values = [e.get('progress', 0) for e in events]
|
||||
has_increasing_progress = progress_values[-1] > progress_values[0]
|
||||
|
||||
print("\n📋 Checks:")
|
||||
print(f" {'✅' if has_progress_updates else '❌'} Multiple progress updates received ({len(events)} events)")
|
||||
print(f" {'✅' if has_increasing_progress else '❌'} Progress increased over time")
|
||||
print(f" {'✅' if has_100_percent else '❌'} Reached 100% progress")
|
||||
print(f" {'✅' if has_complete else '❌'} Received 'complete' status")
|
||||
|
||||
# Overall result
|
||||
success = has_progress_updates and has_complete
|
||||
|
||||
if success:
|
||||
print("\n" + "=" * 70)
|
||||
print("✅ TEST PASSED - Qwen TTS download progress tracking works!")
|
||||
print("=" * 70)
|
||||
else:
|
||||
print("\n" + "=" * 70)
|
||||
print("❌ TEST FAILED - Progress tracking has issues")
|
||||
print("=" * 70)
|
||||
print("\nCheck the server logs for debug output.")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = asyncio.run(main())
|
||||
exit(0 if result else 1)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Test real model download with SSE progress monitoring.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
import time
|
||||
from typing import List, Dict
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 300):
|
||||
"""Monitor SSE stream for a model download."""
|
||||
events: List[Dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
|
||||
print(f"Connecting to SSE endpoint: {url}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
print(f"SSE connected, status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"Error: SSE endpoint returned {response.status_code}")
|
||||
return events
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
print(f" Raw SSE: {line[:100]}...") # Print first 100 chars
|
||||
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
print(f" → {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
|
||||
events.append(data)
|
||||
|
||||
# Stop if complete or error
|
||||
if data.get("status") in ("complete", "error"):
|
||||
print(f" Download {data['status']}!")
|
||||
break
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" Error parsing JSON: {e}")
|
||||
print(f" Line was: {line}")
|
||||
|
||||
elif line.startswith(": heartbeat"):
|
||||
print(" ♥ heartbeat")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def trigger_download(model_name: str):
|
||||
"""Trigger a model download via the API."""
|
||||
url = "http://localhost:8000/models/download"
|
||||
|
||||
print(f"\nTriggering download for: {model_name}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=300) as client:
|
||||
response = await client.post(url, json={"model_name": model_name})
|
||||
print(f"Response: {response.status_code} - {response.json()}")
|
||||
return response.status_code == 200
|
||||
|
||||
|
||||
async def check_server():
|
||||
"""Check if the server is running."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get("http://localhost:8000/health")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
print(f"Server not running: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 60)
|
||||
print("Real Model Download Progress Test")
|
||||
print("=" * 60)
|
||||
|
||||
# Check if server is running
|
||||
print("\nChecking if server is running...")
|
||||
if not await check_server():
|
||||
print("✗ Server is not running on http://localhost:8000")
|
||||
print("\nPlease start the server first:")
|
||||
print(" cd backend && python main.py")
|
||||
return False
|
||||
|
||||
print("✓ Server is running")
|
||||
|
||||
# Choose a small model for testing
|
||||
model_name = "whisper-base" # ~150MB, faster to download
|
||||
print(f"\nUsing model: {model_name}")
|
||||
|
||||
# Option to delete model first if it exists
|
||||
print("\nDo you want to delete the model first to force a fresh download? (y/n)")
|
||||
# For automated testing, skip deletion prompt
|
||||
# delete_first = input().strip().lower() == 'y'
|
||||
delete_first = False
|
||||
|
||||
if delete_first:
|
||||
print(f"Deleting {model_name}...")
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.delete(f"http://localhost:8000/models/{model_name}")
|
||||
print(f"Delete response: {response.status_code}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Starting Test")
|
||||
print("=" * 60)
|
||||
|
||||
# Start monitoring SSE stream BEFORE triggering download
|
||||
async def run_test():
|
||||
# Start SSE monitor in background
|
||||
monitor_task = asyncio.create_task(monitor_sse_stream(model_name))
|
||||
|
||||
# Wait a bit to ensure SSE is connected
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Trigger download
|
||||
success = await trigger_download(model_name)
|
||||
|
||||
if not success:
|
||||
print("✗ Failed to trigger download")
|
||||
monitor_task.cancel()
|
||||
return False
|
||||
|
||||
# Wait for SSE monitor to complete
|
||||
events = await monitor_task
|
||||
|
||||
return events
|
||||
|
||||
events = await run_test()
|
||||
|
||||
# Results
|
||||
print("\n" + "=" * 60)
|
||||
print("Test Results")
|
||||
print("=" * 60)
|
||||
|
||||
if not events:
|
||||
print("✗ FAILED - No SSE events received!")
|
||||
print("\nPossible causes:")
|
||||
print(" 1. SSE endpoint not working")
|
||||
print(" 2. Progress updates not being sent")
|
||||
print(" 3. Model already downloaded (no progress to report)")
|
||||
print("\nTry deleting the model first to force a fresh download:")
|
||||
print(f" curl -X DELETE http://localhost:8000/models/{model_name}")
|
||||
return False
|
||||
|
||||
print(f"✓ Received {len(events)} SSE events")
|
||||
print(f"\nFirst event: {events[0]}")
|
||||
print(f"Last event: {events[-1]}")
|
||||
|
||||
# Check if we got meaningful progress
|
||||
has_progress = any(e.get('progress', 0) > 0 for e in events)
|
||||
has_complete = any(e.get('status') == 'complete' for e in events)
|
||||
|
||||
if has_progress:
|
||||
print("✓ Progress updates received")
|
||||
else:
|
||||
print("✗ No progress updates (might be already downloaded)")
|
||||
|
||||
if has_complete:
|
||||
print("✓ Download completed successfully")
|
||||
else:
|
||||
print("✗ Download did not complete")
|
||||
|
||||
success = has_progress and has_complete
|
||||
|
||||
if success:
|
||||
print("\n✓ TEST PASSED - Progress tracking works!")
|
||||
else:
|
||||
print("\n⊘ TEST INCONCLUSIVE - Try with a fresh download")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+36
-16
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
TTS inference module - delegates to backend abstraction layer.
|
||||
TTS inference module - delegates to provider abstraction layer.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
@@ -7,31 +7,51 @@ import numpy as np
|
||||
import io
|
||||
import soundfile as sf
|
||||
|
||||
from .backends import get_tts_backend, TTSBackend
|
||||
from .backends import TTSBackend
|
||||
from .providers import get_provider_manager
|
||||
from .providers.base import TTSProvider
|
||||
|
||||
|
||||
def get_tts_model() -> TTSBackend:
|
||||
def get_tts_model() -> TTSProvider:
|
||||
"""
|
||||
Get TTS backend instance (MLX or PyTorch based on platform).
|
||||
Get TTS provider instance (via ProviderManager).
|
||||
|
||||
Returns:
|
||||
TTS backend instance
|
||||
TTS provider instance
|
||||
"""
|
||||
return get_tts_backend()
|
||||
manager = get_provider_manager()
|
||||
# Note: This is async but we need sync interface for backward compatibility
|
||||
# In practice, this will be called from async contexts
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# We're in an async context, but can't await here
|
||||
# Return a wrapper that will use the provider manager
|
||||
return manager._get_default_provider()
|
||||
else:
|
||||
return loop.run_until_complete(manager.get_active_provider())
|
||||
except RuntimeError:
|
||||
# No event loop, return default
|
||||
return manager._get_default_provider()
|
||||
|
||||
|
||||
async def get_tts_model_async() -> TTSProvider:
|
||||
"""
|
||||
Get TTS provider instance asynchronously.
|
||||
|
||||
Returns:
|
||||
TTS provider instance
|
||||
"""
|
||||
manager = get_provider_manager()
|
||||
return await manager.get_active_provider()
|
||||
|
||||
|
||||
def unload_tts_model():
|
||||
"""Unload TTS model to free memory."""
|
||||
backend = get_tts_backend()
|
||||
backend.unload_model()
|
||||
|
||||
|
||||
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()
|
||||
manager = get_provider_manager()
|
||||
provider = manager._get_default_provider()
|
||||
provider.unload_model()
|
||||
|
||||
|
||||
def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
|
||||
|
||||
+153
-31
@@ -11,8 +11,9 @@ import sys
|
||||
class HFProgressTracker:
|
||||
"""Tracks HuggingFace Hub download progress by intercepting tqdm."""
|
||||
|
||||
def __init__(self, progress_callback: Optional[Callable] = None):
|
||||
def __init__(self, progress_callback: Optional[Callable] = None, filter_non_downloads: bool = False):
|
||||
self.progress_callback = progress_callback
|
||||
self.filter_non_downloads = filter_non_downloads # Only filter if True
|
||||
self._original_tqdm_class = None
|
||||
self._lock = threading.Lock()
|
||||
self._total_downloaded = 0
|
||||
@@ -21,6 +22,7 @@ class HFProgressTracker:
|
||||
self._file_downloaded = {} # Track downloaded bytes per file
|
||||
self._current_filename = ""
|
||||
self._active_tqdms = {} # Track active tqdm instances
|
||||
self._hf_tqdm_original_update = None # For monkey-patching hf's tqdm
|
||||
|
||||
def _create_tracked_tqdm_class(self):
|
||||
"""Create a tqdm subclass that tracks progress."""
|
||||
@@ -31,7 +33,6 @@ class HFProgressTracker:
|
||||
"""A tqdm subclass that reports progress to our tracker."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
print(f"[DEBUG TrackedTqdm] __init__ called with desc: {kwargs.get('desc', '')}")
|
||||
# Extract filename from desc before passing to parent
|
||||
desc = kwargs.get("desc", "")
|
||||
if not desc and args:
|
||||
@@ -80,7 +81,6 @@ class HFProgressTracker:
|
||||
}
|
||||
|
||||
def update(self, n=1):
|
||||
print(f"[DEBUG TrackedTqdm] update called with n={n}")
|
||||
result = super().update(n)
|
||||
|
||||
# Report progress
|
||||
@@ -91,6 +91,16 @@ class HFProgressTracker:
|
||||
total = getattr(self, "total", 0)
|
||||
|
||||
if total and total > 0:
|
||||
# Always filter out non-byte progress bars (e.g., "Fetching 12 files")
|
||||
# These cause crazy percentages because they're counting files, not bytes
|
||||
if self._is_non_byte_progress(filename):
|
||||
return result
|
||||
|
||||
# When model is cached, also filter out generation-related progress
|
||||
if tracker.filter_non_downloads:
|
||||
if not self._is_download_progress(filename):
|
||||
return result
|
||||
|
||||
# Update per-file tracking
|
||||
tracker._file_sizes[filename] = total
|
||||
tracker._file_downloaded[filename] = current
|
||||
@@ -99,6 +109,13 @@ class HFProgressTracker:
|
||||
tracker._total_size = sum(tracker._file_sizes.values())
|
||||
tracker._total_downloaded = sum(tracker._file_downloaded.values())
|
||||
|
||||
# Only report progress once we have a meaningful total (at least 1MB)
|
||||
# This avoids the "100% at 0MB" issue when small config
|
||||
# files are counted before the real model files
|
||||
MIN_TOTAL_BYTES = 1_000_000 # 1MB
|
||||
if tracker._total_size < MIN_TOTAL_BYTES:
|
||||
return result
|
||||
|
||||
# Call progress callback
|
||||
if tracker.progress_callback:
|
||||
tracker.progress_callback(
|
||||
@@ -109,6 +126,50 @@ class HFProgressTracker:
|
||||
|
||||
return result
|
||||
|
||||
def _is_non_byte_progress(self, filename: str) -> bool:
|
||||
"""Check if this progress bar should be SKIPPED (returns True to skip).
|
||||
|
||||
We want to track byte-based progress bars. This method identifies
|
||||
progress bars that count files/items instead of bytes, which would
|
||||
cause crazy percentages if mixed with our byte counting.
|
||||
|
||||
Returns:
|
||||
True = SKIP this bar (it's not byte-based)
|
||||
False = TRACK this bar (it counts bytes)
|
||||
"""
|
||||
if not filename:
|
||||
return False
|
||||
|
||||
filename_lower = filename.lower()
|
||||
|
||||
# Skip "Fetching X files" - it counts files (total=12), not bytes
|
||||
# Don't skip "Downloading (incomplete total...)" - that IS byte-based
|
||||
skip_patterns = [
|
||||
'fetching', # "Fetching 12 files" has total=12 files, not bytes
|
||||
]
|
||||
return any(pattern in filename_lower for pattern in skip_patterns)
|
||||
|
||||
def _is_download_progress(self, filename: str) -> bool:
|
||||
"""Check if this is a real file download progress bar vs internal processing."""
|
||||
if not filename or filename == "unknown":
|
||||
return False
|
||||
|
||||
# Real downloads have file extensions
|
||||
download_extensions = [
|
||||
'.safetensors', '.bin', '.pt', '.pth', # Model weights
|
||||
'.json', '.txt', '.py', # Config files
|
||||
'.msgpack', '.h5', # Other formats
|
||||
]
|
||||
|
||||
filename_lower = filename.lower()
|
||||
has_extension = any(filename_lower.endswith(ext) for ext in download_extensions)
|
||||
|
||||
# Skip generation-related progress indicators
|
||||
skip_patterns = ['segment', 'processing', 'generating', 'loading']
|
||||
has_skip_pattern = any(pattern in filename_lower for pattern in skip_patterns)
|
||||
|
||||
return has_extension and not has_skip_pattern
|
||||
|
||||
def close(self):
|
||||
with tracker._lock:
|
||||
if id(self) in tracker._active_tqdms:
|
||||
@@ -120,13 +181,11 @@ class HFProgressTracker:
|
||||
@contextmanager
|
||||
def patch_download(self):
|
||||
"""Context manager to patch tqdm for progress tracking."""
|
||||
print("[DEBUG HFProgressTracker] patch_download called")
|
||||
try:
|
||||
import tqdm as tqdm_module
|
||||
|
||||
# Store original tqdm class
|
||||
self._original_tqdm_class = tqdm_module.tqdm
|
||||
print(f"[DEBUG HFProgressTracker] Original tqdm class: {self._original_tqdm_class}")
|
||||
|
||||
# Reset totals
|
||||
with self._lock:
|
||||
@@ -139,39 +198,89 @@ class HFProgressTracker:
|
||||
|
||||
# Create our tracked tqdm class
|
||||
tracked_tqdm = self._create_tracked_tqdm_class()
|
||||
print(f"[DEBUG HFProgressTracker] Created TrackedTqdm class: {tracked_tqdm}")
|
||||
|
||||
# Patch tqdm.tqdm
|
||||
tqdm_module.tqdm = tracked_tqdm
|
||||
print(f"[DEBUG HFProgressTracker] Patched tqdm.tqdm")
|
||||
|
||||
# Also patch tqdm.auto.tqdm if it exists (used by huggingface_hub)
|
||||
self._original_tqdm_auto = None
|
||||
if hasattr(tqdm_module, "auto") and hasattr(tqdm_module.auto, "tqdm"):
|
||||
self._original_tqdm_auto = tqdm_module.auto.tqdm
|
||||
tqdm_module.auto.tqdm = tracked_tqdm
|
||||
print(f"[DEBUG HFProgressTracker] Patched tqdm.auto.tqdm")
|
||||
|
||||
# Patch in sys.modules to catch already-imported references
|
||||
# huggingface_hub uses: from tqdm.auto import tqdm as base_tqdm
|
||||
# So we need to patch both 'tqdm' and 'base_tqdm' attributes
|
||||
self._patched_modules = {}
|
||||
tqdm_attr_names = ['tqdm', 'base_tqdm', 'old_tqdm'] # Various names used
|
||||
|
||||
patched_count = 0
|
||||
for module_name in list(sys.modules.keys()):
|
||||
if "huggingface" in module_name or module_name.startswith("tqdm"):
|
||||
try:
|
||||
module = sys.modules[module_name]
|
||||
if hasattr(module, "tqdm"):
|
||||
attr = getattr(module, "tqdm")
|
||||
# Only patch if it's the original tqdm class (not already patched)
|
||||
if attr is self._original_tqdm_class or (
|
||||
hasattr(attr, "__name__") and attr.__name__ == "tqdm"
|
||||
):
|
||||
self._patched_modules[module_name] = attr
|
||||
setattr(module, "tqdm", tracked_tqdm)
|
||||
patched_count += 1
|
||||
print(f"[DEBUG HFProgressTracker] Patched {module_name}.tqdm")
|
||||
for attr_name in tqdm_attr_names:
|
||||
if hasattr(module, attr_name):
|
||||
attr = getattr(module, attr_name)
|
||||
# Only patch if it's a tqdm class (not already patched)
|
||||
is_tqdm_class = (
|
||||
attr is self._original_tqdm_class or
|
||||
(self._original_tqdm_auto and attr is self._original_tqdm_auto) or
|
||||
(hasattr(attr, "__name__") and attr.__name__ == "tqdm" and
|
||||
hasattr(attr, "update")) # tqdm classes have update method
|
||||
)
|
||||
if is_tqdm_class:
|
||||
key = f"{module_name}.{attr_name}"
|
||||
self._patched_modules[key] = (module, attr_name, attr)
|
||||
setattr(module, attr_name, tracked_tqdm)
|
||||
patched_count += 1
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
print(f"[DEBUG HFProgressTracker] Patched {patched_count} modules in sys.modules")
|
||||
|
||||
# ALSO monkey-patch the update method on huggingface_hub's tqdm class
|
||||
# This is needed because the class was already defined at import time
|
||||
self._hf_tqdm_original_update = None
|
||||
try:
|
||||
from huggingface_hub.utils import tqdm as hf_tqdm_module
|
||||
if hasattr(hf_tqdm_module, 'tqdm'):
|
||||
hf_tqdm_class = hf_tqdm_module.tqdm
|
||||
self._hf_tqdm_original_update = hf_tqdm_class.update
|
||||
|
||||
# Create a wrapper that calls our tracking
|
||||
tracker = self # Reference to HFProgressTracker instance
|
||||
def patched_update(tqdm_self, n=1):
|
||||
result = tracker._hf_tqdm_original_update(tqdm_self, n)
|
||||
|
||||
# Track this progress
|
||||
with tracker._lock:
|
||||
desc = getattr(tqdm_self, 'desc', '') or ''
|
||||
current = getattr(tqdm_self, 'n', 0)
|
||||
total = getattr(tqdm_self, 'total', 0) or 0
|
||||
|
||||
# Skip non-byte progress bars
|
||||
if 'fetching' in desc.lower():
|
||||
return result
|
||||
|
||||
# Skip until we have a meaningful total (at least 1MB)
|
||||
# This avoids the "100% at 0MB" issue when small config
|
||||
# files are counted before the real model files
|
||||
MIN_TOTAL_BYTES = 1_000_000 # 1MB
|
||||
if total >= MIN_TOTAL_BYTES:
|
||||
tracker._total_downloaded = current
|
||||
tracker._total_size = total
|
||||
|
||||
if tracker.progress_callback:
|
||||
tracker.progress_callback(current, total, desc)
|
||||
|
||||
return result
|
||||
|
||||
hf_tqdm_class.update = patched_update
|
||||
patched_count += 1
|
||||
print(f"[HFProgressTracker] Monkey-patched huggingface_hub.utils.tqdm.tqdm.update")
|
||||
except (ImportError, AttributeError) as e:
|
||||
print(f"[HFProgressTracker] Could not monkey-patch hf_tqdm: {e}")
|
||||
|
||||
print(f"[HFProgressTracker] Patched {patched_count} tqdm references")
|
||||
|
||||
yield
|
||||
|
||||
@@ -189,15 +298,24 @@ class HFProgressTracker:
|
||||
tqdm_module.auto.tqdm = self._original_tqdm_auto
|
||||
|
||||
# Restore patched modules
|
||||
for module_name, original in self._patched_modules.items():
|
||||
for key, (module, attr_name, original) in self._patched_modules.items():
|
||||
try:
|
||||
module = sys.modules.get(module_name)
|
||||
if module and original:
|
||||
setattr(module, "tqdm", original)
|
||||
setattr(module, attr_name, original)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
self._patched_modules = {}
|
||||
|
||||
# Restore hf_tqdm's original update method
|
||||
if self._hf_tqdm_original_update:
|
||||
try:
|
||||
from huggingface_hub.utils import tqdm as hf_tqdm_module
|
||||
if hasattr(hf_tqdm_module, 'tqdm'):
|
||||
hf_tqdm_module.tqdm.update = self._hf_tqdm_original_update
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
self._hf_tqdm_original_update = None
|
||||
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
@@ -205,13 +323,17 @@ class HFProgressTracker:
|
||||
def create_hf_progress_callback(model_name: str, progress_manager):
|
||||
"""Create a progress callback for HuggingFace downloads."""
|
||||
def callback(downloaded: int, total: int, filename: str = ""):
|
||||
"""Progress callback."""
|
||||
if total > 0:
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=downloaded,
|
||||
total=total,
|
||||
filename=filename or "",
|
||||
status="downloading",
|
||||
)
|
||||
"""Progress callback.
|
||||
|
||||
Note: We send updates even when total=0 (unknown) to provide feedback
|
||||
during the "incomplete total" phase of huggingface_hub downloads.
|
||||
The frontend handles total=0 gracefully.
|
||||
"""
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=downloaded,
|
||||
total=total,
|
||||
filename=filename or "",
|
||||
status="downloading",
|
||||
)
|
||||
return callback
|
||||
|
||||
+52
-15
@@ -16,11 +16,17 @@ class ProgressManager:
|
||||
Thread-safe: can be called from background threads (e.g., via asyncio.to_thread).
|
||||
"""
|
||||
|
||||
# Throttle settings to prevent overwhelming SSE clients
|
||||
THROTTLE_INTERVAL_SECONDS = 0.5 # Minimum time between updates
|
||||
THROTTLE_PROGRESS_DELTA = 1.0 # Minimum progress change (%) to force update
|
||||
|
||||
def __init__(self):
|
||||
self._progress: Dict[str, Dict] = {}
|
||||
self._listeners: Dict[str, list] = {}
|
||||
self._lock = threading.Lock() # Thread-safe lock for progress dict
|
||||
self._main_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._last_notify_time: Dict[str, float] = {} # Last notification time per model
|
||||
self._last_notify_progress: Dict[str, float] = {} # Last notified progress per model
|
||||
|
||||
def _set_main_loop(self, loop: asyncio.AbstractEventLoop):
|
||||
"""Set the main event loop for thread-safe operations."""
|
||||
@@ -43,11 +49,17 @@ class ProgressManager:
|
||||
queue.put_nowait(progress_data.copy())
|
||||
except RuntimeError:
|
||||
# Not in async context (running in background thread)
|
||||
# Use call_soon_threadsafe to safely put on queue
|
||||
# Use asyncio.run_coroutine_threadsafe for better PyInstaller compatibility
|
||||
if self._main_loop and self._main_loop.is_running():
|
||||
self._main_loop.call_soon_threadsafe(
|
||||
lambda q=queue, d=progress_data.copy(): q.put_nowait(d) if not q.full() else None
|
||||
)
|
||||
async def put_data_async():
|
||||
try:
|
||||
queue.put_nowait(progress_data.copy())
|
||||
except asyncio.QueueFull:
|
||||
pass # Queue full, drop update
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(put_data_async(), self._main_loop)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to schedule progress update: {e}")
|
||||
else:
|
||||
logger.debug(f"No main loop available for {model_name}, skipping notification")
|
||||
except asyncio.QueueFull:
|
||||
@@ -67,6 +79,10 @@ class ProgressManager:
|
||||
Update progress for a model download.
|
||||
|
||||
Thread-safe: can be called from background threads.
|
||||
|
||||
Progress updates are throttled to prevent overwhelming SSE clients.
|
||||
Updates are sent at most every THROTTLE_INTERVAL_SECONDS, or when
|
||||
progress changes by at least THROTTLE_PROGRESS_DELTA percent.
|
||||
|
||||
Args:
|
||||
model_name: Name of the model (e.g., "qwen-tts-1.7B", "whisper-base")
|
||||
@@ -76,9 +92,17 @@ class ProgressManager:
|
||||
status: Status string (downloading, extracting, complete, error)
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
progress_pct = (current / total * 100) if total > 0 else 0
|
||||
# Calculate progress percentage, clamped to 0-100 range
|
||||
# This prevents crazy percentages from edge cases like:
|
||||
# - current > total temporarily during aggregation
|
||||
# - mixing file-count progress with byte-count progress
|
||||
if total > 0:
|
||||
progress_pct = min(100.0, max(0.0, (current / total * 100)))
|
||||
else:
|
||||
progress_pct = 0
|
||||
|
||||
progress_data = {
|
||||
"model_name": model_name,
|
||||
@@ -90,25 +114,38 @@ class ProgressManager:
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
print(f"[DEBUG] update_progress called: {model_name}, {progress_pct:.1f}%")
|
||||
|
||||
# Thread-safe update of progress dict
|
||||
# Thread-safe update of progress dict (always update internal state)
|
||||
with self._lock:
|
||||
self._progress[model_name] = progress_data
|
||||
|
||||
# Check if we should notify listeners (throttling)
|
||||
current_time = time.time()
|
||||
last_time = self._last_notify_time.get(model_name, 0)
|
||||
last_progress = self._last_notify_progress.get(model_name, -100)
|
||||
|
||||
time_delta = current_time - last_time
|
||||
progress_delta = abs(progress_pct - last_progress)
|
||||
|
||||
# Always notify for complete/error status, or if throttle conditions are met
|
||||
should_notify = (
|
||||
status in ("complete", "error") or
|
||||
time_delta >= self.THROTTLE_INTERVAL_SECONDS or
|
||||
progress_delta >= self.THROTTLE_PROGRESS_DELTA
|
||||
)
|
||||
|
||||
if not should_notify:
|
||||
return # Skip this update (throttled)
|
||||
|
||||
# Update throttle tracking
|
||||
self._last_notify_time[model_name] = current_time
|
||||
self._last_notify_progress[model_name] = progress_pct
|
||||
|
||||
# Notify all listeners (thread-safe)
|
||||
listener_count = len(self._listeners.get(model_name, []))
|
||||
print(f"[DEBUG] Listener count for {model_name}: {listener_count}")
|
||||
print(f"[DEBUG] All listeners: {list(self._listeners.keys())}")
|
||||
print(f"[DEBUG] Main loop set: {self._main_loop is not None}")
|
||||
if self._main_loop:
|
||||
print(f"[DEBUG] Main loop running: {self._main_loop.is_running()}")
|
||||
|
||||
if listener_count > 0:
|
||||
logger.debug(f"Notifying {listener_count} listeners for {model_name}: {progress_pct:.1f}% ({filename})")
|
||||
print(f"[DEBUG] About to notify listeners...")
|
||||
self._notify_listeners_threadsafe(model_name, progress_data)
|
||||
print(f"[DEBUG] Notified listeners")
|
||||
else:
|
||||
logger.debug(f"No listeners for {model_name}, progress update stored: {progress_pct:.1f}%")
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
# -*- 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', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
||||
datas += collect_data_files('qwen_tts')
|
||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.providers', 'backend.providers.base', 'backend.providers.bundled', 'backend.providers.types', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'pkg_resources.extern', 'asyncio', 'asyncio.subprocess', 'concurrent.futures', 'concurrent.futures.thread', 'backend.backends', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
||||
datas += collect_data_files('mlx')
|
||||
datas += collect_data_files('mlx_audio')
|
||||
datas += copy_metadata('qwen-tts')
|
||||
hiddenimports += collect_submodules('qwen_tts')
|
||||
hiddenimports += collect_submodules('jaraco')
|
||||
hiddenimports += collect_submodules('mlx')
|
||||
hiddenimports += collect_submodules('mlx_audio')
|
||||
|
||||
@@ -13,12 +13,16 @@
|
||||
},
|
||||
"app": {
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.4",
|
||||
"@iconify-json/svg-spinners": "^1.2.4",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
@@ -26,6 +30,7 @@
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
@@ -45,7 +50,6 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"framer-motion": "^12.29.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"motion": "^12.29.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
@@ -68,7 +72,7 @@
|
||||
},
|
||||
"landing": {
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
@@ -93,10 +97,14 @@
|
||||
},
|
||||
"tauri": {
|
||||
"name": "@voicebox/tauri",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
@@ -112,7 +120,7 @@
|
||||
},
|
||||
"web": {
|
||||
"name": "@voicebox/web",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"react": "^18.3.0",
|
||||
@@ -121,6 +129,7 @@
|
||||
"zustand": "^4.5.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
@@ -129,6 +138,7 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
},
|
||||
@@ -267,12 +277,22 @@
|
||||
|
||||
"@hookform/resolvers": ["@hookform/[email protected]", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="],
|
||||
|
||||
"@hugeicons/core-free-icons": ["@hugeicons/[email protected]", "", {}, "sha512-UpS2lUQFi5sKyJSWwM6rO+BnPLvVz1gsyCpPHeZyVuZqi89YH8ksliza4cwaODqKOZyeXmG8juo1ty4QtQofkg=="],
|
||||
|
||||
"@hugeicons/react": ["@hugeicons/[email protected]", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-gsc3eZyd2fGqRUThW9+lfjxxsOkz6KNVmRXRgJjP32GL0OnnLJnl3hytKt47CBbiQj2xE2kCw+rnP3UQCThcKw=="],
|
||||
|
||||
"@humanwhocodes/config-array": ["@humanwhocodes/[email protected]", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/[email protected]", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/object-schema": ["@humanwhocodes/[email protected]", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
|
||||
|
||||
"@iconify-json/svg-spinners": ["@iconify-json/[email protected]", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-ayn0pogFPwJA1WFZpDnoq9/hjDxN+keeCMyThaX4d3gSJ3y0mdKUxIA/b1YXWGtY9wVtZmxwcvOIeEieG4+JNg=="],
|
||||
|
||||
"@iconify/react": ["@iconify/[email protected]", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg=="],
|
||||
|
||||
"@iconify/types": ["@iconify/[email protected]", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@img/colour": ["@img/[email protected]", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
@@ -403,6 +423,8 @@
|
||||
|
||||
"@radix-ui/react-progress": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="],
|
||||
|
||||
"@radix-ui/react-radio-group": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
"@radix-ui/react-scroll-area": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||
@@ -877,7 +899,7 @@
|
||||
|
||||
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.454.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ=="],
|
||||
"lucide-react": ["lucide-react@0.316.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-dTmYX1H4IXsRfVcj/KUxworV6814ApTl7iXaS21AimK2RUEl4j4AfOmqD3VR8phe5V91m4vEJ8tCK4uT1jE5nA=="],
|
||||
|
||||
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
@@ -1133,8 +1155,6 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"@voicebox/landing/lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-dTmYX1H4IXsRfVcj/KUxworV6814ApTl7iXaS21AimK2RUEl4j4AfOmqD3VR8phe5V91m4vEJ8tCK4uT1jE5nA=="],
|
||||
|
||||
"@voicebox/landing/tailwind-merge": ["[email protected]", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||
|
||||
"@voicebox/landing/tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# User data directory
|
||||
# This directory contains:
|
||||
# - profiles/ - Voice profile audio files
|
||||
# - generations/ - Generated audio files
|
||||
# - projects/ - Audio studio project files
|
||||
# - voicebox.db - SQLite database
|
||||
# - cache/ - Voice prompt cache files
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
# Voice prompt cache files
|
||||
@@ -0,0 +1,26 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:latest
|
||||
container_name: voicebox
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- voicebox-data:/app/data
|
||||
- huggingface-cache:/root/.cache/huggingface
|
||||
environment:
|
||||
- LOG_LEVEL=info
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
voicebox-data:
|
||||
driver: local
|
||||
huggingface-cache:
|
||||
driver: local
|
||||
@@ -0,0 +1,34 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
container_name: voicebox
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- voicebox-data:/app/data
|
||||
- huggingface-cache:/root/.cache/huggingface
|
||||
environment:
|
||||
- GPU_MEMORY_FRACTION=0.8
|
||||
- LOG_LEVEL=info
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
voicebox-data:
|
||||
driver: local
|
||||
huggingface-cache:
|
||||
driver: local
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"icon": "rocket",
|
||||
"pages": ["overview/introduction", "overview/installation", "overview/quick-start"]
|
||||
"pages": ["overview/introduction", "overview/installation", "overview/docker", "overview/quick-start"]
|
||||
},
|
||||
{
|
||||
"group": "Features",
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
---
|
||||
title: "Docker Deployment"
|
||||
description: "Run Voicebox in Docker with the web UI for server deployments"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Voicebox is available as Docker images that include both the backend API and web UI. Run the full Voicebox experience in a container with a single command.
|
||||
|
||||
**What's included:**
|
||||
- FastAPI backend with all TTS/Whisper capabilities
|
||||
- Complete web UI (same React app as the desktop version)
|
||||
- Provider download system (downloads PyTorch on first use)
|
||||
- Multi-architecture support (amd64, arm64)
|
||||
|
||||
## Quick Start
|
||||
|
||||
<Tabs>
|
||||
<Tab title="NVIDIA GPU">
|
||||
```bash
|
||||
docker run --gpus all -p 8000:8000 \
|
||||
-v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
```
|
||||
|
||||
Then open http://localhost:8000 to access the web UI.
|
||||
</Tab>
|
||||
|
||||
<Tab title="CPU Only">
|
||||
```bash
|
||||
docker run -p 8000:8000 \
|
||||
-v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest
|
||||
```
|
||||
|
||||
Then open http://localhost:8000 to access the web UI.
|
||||
</Tab>
|
||||
|
||||
<Tab title="Docker Compose">
|
||||
Clone the repo or download `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
# CUDA variant (default)
|
||||
docker compose up -d
|
||||
|
||||
# CPU-only variant
|
||||
docker compose -f docker-compose-cpu.yml up -d
|
||||
```
|
||||
|
||||
Then open http://localhost:8000 to access the web UI.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Note>
|
||||
On first launch, you'll be prompted to download a TTS provider (PyTorch CPU ~300MB or PyTorch CUDA ~2.4GB). This happens once and is cached in the `huggingface-cache` volume.
|
||||
</Note>
|
||||
|
||||
## Available Images
|
||||
|
||||
Images are automatically built and published to GitHub Container Registry on each release.
|
||||
|
||||
| Image | Description | Platforms |
|
||||
|-------|-------------|-----------|
|
||||
| `ghcr.io/jamiepine/voicebox:latest` | Latest CPU-only release | linux/amd64, linux/arm64 |
|
||||
| `ghcr.io/jamiepine/voicebox:0.1.13` | Specific version (CPU) | linux/amd64, linux/arm64 |
|
||||
| `ghcr.io/jamiepine/voicebox:latest-cuda` | Latest with NVIDIA GPU support | linux/amd64 |
|
||||
| `ghcr.io/jamiepine/voicebox:0.1.13-cuda` | Specific version (CUDA) | linux/amd64 |
|
||||
|
||||
<Tip>
|
||||
Pin to a specific version in production to avoid unexpected updates:
|
||||
```yaml
|
||||
image: ghcr.io/jamiepine/voicebox:0.1.13-cuda
|
||||
```
|
||||
</Tip>
|
||||
|
||||
## Docker Compose Examples
|
||||
|
||||
### GPU Deployment (Recommended)
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
container_name: voicebox
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- voicebox-data:/app/data
|
||||
- huggingface-cache:/root/.cache/huggingface
|
||||
environment:
|
||||
- GPU_MEMORY_FRACTION=0.8
|
||||
- LOG_LEVEL=info
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
volumes:
|
||||
voicebox-data:
|
||||
huggingface-cache:
|
||||
```
|
||||
|
||||
### CPU Deployment
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:latest
|
||||
container_name: voicebox
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- voicebox-data:/app/data
|
||||
- huggingface-cache:/root/.cache/huggingface
|
||||
environment:
|
||||
- LOG_LEVEL=info
|
||||
|
||||
volumes:
|
||||
voicebox-data:
|
||||
huggingface-cache:
|
||||
```
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="voicebox-data" icon="database">
|
||||
Stores voice profiles, generated audio, and database
|
||||
</Card>
|
||||
<Card title="huggingface-cache" icon="download">
|
||||
Caches downloaded TTS/Whisper models (saves re-downloading)
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Warning>
|
||||
Always mount `/app/data` to preserve your voice profiles and generations across container restarts.
|
||||
</Warning>
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Configure Voicebox behavior with environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `GPU_MEMORY_FRACTION` | `0.9` | Fraction of GPU memory to use (0.0-1.0) |
|
||||
| `LOG_LEVEL` | `info` | Logging level: `debug`, `info`, `warning`, `error` |
|
||||
| `DATA_DIR` | `/app/data` | Directory for profiles and generations |
|
||||
|
||||
Example:
|
||||
```bash
|
||||
docker run -e GPU_MEMORY_FRACTION=0.8 \
|
||||
-e LOG_LEVEL=debug \
|
||||
-p 8000:8000 \
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
```
|
||||
|
||||
## Cloud Deployment
|
||||
|
||||
### AWS EC2
|
||||
|
||||
<Steps>
|
||||
<Step title="Launch GPU Instance">
|
||||
Use g4dn.xlarge or p3.2xlarge with NVIDIA GPU
|
||||
</Step>
|
||||
|
||||
<Step title="Install Docker & NVIDIA Container Toolkit">
|
||||
```bash
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
|
||||
# Install NVIDIA Container Toolkit
|
||||
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
|
||||
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
||||
curl -s -L https://nvidia.github.io/libnvidia-container/$distribution/libnvidia-container.list | \
|
||||
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
|
||||
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nvidia-container-toolkit
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Deploy">
|
||||
```bash
|
||||
docker run -d --gpus all -p 8000:8000 \
|
||||
-v voicebox-data:/app/data \
|
||||
--restart unless-stopped \
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### DigitalOcean
|
||||
|
||||
<Steps>
|
||||
<Step title="Create GPU Droplet">
|
||||
```bash
|
||||
doctl compute droplet create voicebox \
|
||||
--size gpu-h100x1-80gb \
|
||||
--image ubuntu-22-04-x64 \
|
||||
--region nyc3
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="SSH and Deploy">
|
||||
```bash
|
||||
ssh root@<droplet-ip>
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
docker run -d --gpus all -p 80:8000 \
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Fly.io
|
||||
|
||||
Create `fly.toml`:
|
||||
|
||||
```toml
|
||||
app = "voicebox"
|
||||
|
||||
[build]
|
||||
image = "ghcr.io/jamiepine/voicebox:latest"
|
||||
|
||||
[[services]]
|
||||
http_checks = []
|
||||
internal_port = 8000
|
||||
protocol = "tcp"
|
||||
|
||||
[[services.ports]]
|
||||
port = 80
|
||||
handlers = ["http"]
|
||||
|
||||
[[services.ports]]
|
||||
port = 443
|
||||
handlers = ["tls", "http"]
|
||||
|
||||
[mounts]
|
||||
source = "voicebox_data"
|
||||
destination = "/app/data"
|
||||
```
|
||||
|
||||
Deploy:
|
||||
```bash
|
||||
fly launch
|
||||
fly deploy
|
||||
```
|
||||
|
||||
## Updates
|
||||
|
||||
Docker images are automatically built and published on each GitHub release.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Latest Tag">
|
||||
Always get the newest version:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/jamiepine/voicebox:latest
|
||||
docker compose up -d
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="Pinned Version">
|
||||
Update to a specific version:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:0.1.13-cuda
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="Automatic Updates">
|
||||
Use Watchtower for automatic updates:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
# ... other config ...
|
||||
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
command: --interval 3600 # Check hourly
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## GPU Requirements
|
||||
|
||||
### NVIDIA GPU
|
||||
|
||||
Requires:
|
||||
- **Docker version:** 19.03+
|
||||
- **NVIDIA Driver:** 450.80.02+
|
||||
- **NVIDIA Container Toolkit:** Installed and configured
|
||||
|
||||
Verify GPU access:
|
||||
```bash
|
||||
docker run --rm --gpus all nvidia/cuda:12.1.1-base-ubuntu22.04 nvidia-smi
|
||||
```
|
||||
|
||||
If this works, Voicebox will detect and use your GPU automatically.
|
||||
|
||||
### AMD GPU (ROCm)
|
||||
|
||||
AMD GPU support via ROCm is not currently available in pre-built images. If you need ROCm support, build a custom image using the ROCm base.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### GPU Not Detected
|
||||
|
||||
<Accordion title="Check NVIDIA Docker">
|
||||
```bash
|
||||
# Verify NVIDIA Container Toolkit is installed
|
||||
docker run --rm --gpus all nvidia/cuda:12.1.1-base-ubuntu22.04 nvidia-smi
|
||||
```
|
||||
|
||||
If this fails, reinstall NVIDIA Container Toolkit.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Insufficient GPU Memory">
|
||||
Reduce GPU memory usage:
|
||||
|
||||
```bash
|
||||
docker run -e GPU_MEMORY_FRACTION=0.5 \
|
||||
--gpus all -p 8000:8000 \
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
```
|
||||
|
||||
Or use CPU-only mode:
|
||||
```bash
|
||||
docker run -p 8000:8000 \
|
||||
ghcr.io/jamiepine/voicebox:latest
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Port Already in Use">
|
||||
Change the host port:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8000 ghcr.io/jamiepine/voicebox:latest
|
||||
```
|
||||
|
||||
Then open http://localhost:8080
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Permission Errors">
|
||||
Run with specific user:
|
||||
|
||||
```bash
|
||||
docker run --user $(id -u):$(id -g) \
|
||||
-v $(pwd)/data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
## Building From Source
|
||||
|
||||
If you need to customize the Docker image:
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/jamiepine/voicebox.git
|
||||
cd voicebox
|
||||
|
||||
# Build web UI
|
||||
bun install
|
||||
cd web && bun run build && cd ..
|
||||
|
||||
# Build Docker image
|
||||
docker build -t voicebox:custom .
|
||||
|
||||
# Or CUDA variant
|
||||
docker build -f Dockerfile.cuda -t voicebox:custom-cuda .
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="API Reference" icon="code" href="/api/overview">
|
||||
Integrate Voicebox into your applications
|
||||
</Card>
|
||||
<Card title="Remote Mode" icon="server" href="/overview/remote-mode">
|
||||
Connect desktop app to Docker backend
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -5,15 +5,21 @@ description: "Download and install Voicebox on macOS, Windows, or Linux"
|
||||
|
||||
## Download
|
||||
|
||||
Voicebox is available for macOS and Windows, with Linux builds coming soon.
|
||||
Voicebox is available for macOS, Windows, and Linux.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<CardGroup cols={4}>
|
||||
<Card title="macOS" icon="apple">
|
||||
Download for Apple Silicon or Intel Macs
|
||||
</Card>
|
||||
<Card title="Windows" icon="windows">
|
||||
Download MSI installer or Setup executable
|
||||
</Card>
|
||||
<Card title="Linux" icon="linux">
|
||||
Download AppImage or Deb package
|
||||
</Card>
|
||||
<Card title="Docker" icon="docker" href="/overview/docker">
|
||||
Run with web UI in a container
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
### macOS
|
||||
@@ -60,8 +66,33 @@ Voicebox is available for macOS and Windows, with Linux builds coming soon.
|
||||
|
||||
### Linux
|
||||
|
||||
<Tabs>
|
||||
<Tab title="AppImage">
|
||||
Download: [voicebox_x86_64.AppImage](https://github.com/jamiepine/voicebox/releases/latest)
|
||||
|
||||
```bash
|
||||
# Make executable
|
||||
chmod +x voicebox_x86_64.AppImage
|
||||
|
||||
# Run
|
||||
./voicebox_x86_64.AppImage
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Debian/Ubuntu">
|
||||
Download: [voicebox_amd64.deb](https://github.com/jamiepine/voicebox/releases/latest)
|
||||
|
||||
```bash
|
||||
# Install
|
||||
sudo dpkg -i voicebox_amd64.deb
|
||||
|
||||
# Run
|
||||
voicebox
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Note>
|
||||
Linux builds are coming soon. Currently blocked by GitHub runner disk space limitations.
|
||||
For headless server deployments, use [Docker](/overview/docker) instead of the desktop app.
|
||||
</Note>
|
||||
|
||||
## First Launch
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "Welcome to Voicebox - the open-source voice synthesis studio"
|
||||
|
||||
## What is Voicebox?
|
||||
|
||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as the **Ollama for voice** — download models, clone voices, and generate speech entirely on your machine.
|
||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as a **local, free and open-source alternative to ElevenLabs** — download models, clone voices, and generate speech entirely on your machine.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/app-screenshot-1.webp" alt="Voicebox App Screenshot" />
|
||||
|
||||
+72
-193
@@ -1,24 +1,31 @@
|
||||
# Docker Deployment Guide
|
||||
|
||||
**Status:** In Development for v0.2.0
|
||||
**Requested By:** Reddit community ([thread](https://reddit.com/r/LocalLLaMA/...))
|
||||
**Status:** Implemented
|
||||
**Images:** `ghcr.io/jamiepine/voicebox`
|
||||
|
||||
## Overview
|
||||
|
||||
Docker support makes Voicebox easier to deploy, especially for:
|
||||
Voicebox is available as Docker images with the full web UI included. Images are automatically built and published to GitHub Container Registry on each release.
|
||||
|
||||
- **Consistent Environments**: Same setup across dev/staging/prod
|
||||
- **GPU Passthrough**: Easy NVIDIA/AMD GPU access
|
||||
**What's included:**
|
||||
- FastAPI backend with all TTS/Whisper capabilities
|
||||
- Complete web UI (same React app as the Tauri desktop version)
|
||||
- Provider download system (downloads TTS providers on first use, just like desktop)
|
||||
- Multi-architecture support (amd64, arm64 for CPU variant)
|
||||
|
||||
Docker support is ideal for:
|
||||
- **Server Deployments**: Run on headless Linux servers
|
||||
- **Multi-User Setups**: Isolate instances per user/team
|
||||
- **GPU Passthrough**: Easy NVIDIA GPU access
|
||||
- **Consistent Environments**: Same setup across dev/staging/prod
|
||||
- **Cloud Platforms**: Deploy to AWS, GCP, Azure, DigitalOcean
|
||||
- **Multi-User Setups**: Isolate instances per user/team
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Pre-Built Images (Recommended)
|
||||
|
||||
```bash
|
||||
# CPU-only version
|
||||
# CPU-only version (supports amd64 and arm64)
|
||||
docker run -p 8000:8000 -v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest
|
||||
|
||||
@@ -26,184 +33,80 @@ docker run -p 8000:8000 -v voicebox-data:/app/data \
|
||||
docker run --gpus all -p 8000:8000 -v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
|
||||
# AMD GPU version (experimental)
|
||||
docker run --device=/dev/kfd --device=/dev/dri -p 8000:8000 \
|
||||
-v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest-rocm
|
||||
# Specific version (pinned for stability)
|
||||
docker run -p 8000:8000 -v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:0.1.13
|
||||
```
|
||||
|
||||
Then open: `http://localhost:8000`
|
||||
|
||||
The web UI will load automatically. On first use, you'll be prompted to download a TTS provider (PyTorch CPU ~300MB or PyTorch CUDA ~2.4GB).
|
||||
|
||||
### Using Docker Compose (Easiest)
|
||||
|
||||
Create `docker-compose.yml`:
|
||||
Use the provided `docker-compose.yml` (CUDA) or `docker-compose.cpu.yml` in the repository root:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
```bash
|
||||
# CUDA (default)
|
||||
docker compose up -d
|
||||
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- voicebox-data:/app/data
|
||||
- huggingface-cache:/root/.cache/huggingface
|
||||
environment:
|
||||
- GPU_MEMORY_FRACTION=0.8 # Use 80% of GPU memory
|
||||
- TTS_MODE=local
|
||||
- WHISPER_MODE=local
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
volumes:
|
||||
voicebox-data:
|
||||
huggingface-cache:
|
||||
# Or CPU-only
|
||||
docker compose -f docker-compose.cpu.yml up -d
|
||||
```
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker compose up -d
|
||||
To pin to a specific version, edit the compose file:
|
||||
```yaml
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:0.1.13-cuda # Pinned version
|
||||
```
|
||||
|
||||
## Building From Source
|
||||
|
||||
### Basic Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
build-essential \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy application
|
||||
COPY backend/ /app/backend/
|
||||
COPY requirements.txt /app/
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run server
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
See `Dockerfile` and `Dockerfile.cuda` in the repository root.
|
||||
|
||||
Build and run:
|
||||
```bash
|
||||
# Build web UI first
|
||||
bun install
|
||||
cd web && bun run build && cd ..
|
||||
|
||||
# Build CPU image
|
||||
docker build -t voicebox .
|
||||
docker run -p 8000:8000 -v $(pwd)/data:/app/data voicebox
|
||||
docker run -p 8000:8000 -v voicebox-data:/app/data voicebox
|
||||
|
||||
# Or build CUDA image
|
||||
docker build -f Dockerfile.cuda -t voicebox:cuda .
|
||||
docker run --gpus all -p 8000:8000 -v voicebox-data:/app/data voicebox:cuda
|
||||
```
|
||||
|
||||
### Multi-Stage Build (Optimized)
|
||||
### Architecture
|
||||
|
||||
Smaller image size by separating build and runtime:
|
||||
The Docker images include:
|
||||
- **Backend**: FastAPI server with TTS/Whisper endpoints
|
||||
- **Web UI**: Pre-built React app served as static files from the backend
|
||||
- **Provider System**: Downloads PyTorch CPU/CUDA providers on first use (same UX as desktop app)
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile.optimized
|
||||
# Stage 1: Build dependencies
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git build-essential && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir --target=/build/packages \
|
||||
-r requirements.txt
|
||||
|
||||
RUN pip install --no-cache-dir --target=/build/packages \
|
||||
git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed packages from builder
|
||||
COPY --from=builder /build/packages /usr/local/lib/python3.11/site-packages/
|
||||
|
||||
# Copy application code
|
||||
COPY backend/ /app/backend/
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
Build:
|
||||
```bash
|
||||
docker build -f Dockerfile.optimized -t voicebox:slim .
|
||||
```
|
||||
Images are automatically built on release and tagged with both version number and `latest`.
|
||||
|
||||
## GPU Support
|
||||
|
||||
### NVIDIA GPUs (CUDA)
|
||||
|
||||
**Dockerfile:**
|
||||
```dockerfile
|
||||
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
|
||||
|
||||
# Install Python
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3.11 python3-pip git ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install PyTorch with CUDA support
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||
|
||||
# Install other dependencies
|
||||
RUN pip3 install -r requirements.txt
|
||||
RUN pip3 install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
|
||||
COPY backend/ /app/backend/
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
The CUDA image includes PyTorch with CUDA 12.1 support:
|
||||
|
||||
**Run with GPU:**
|
||||
```bash
|
||||
docker run --gpus all -p 8000:8000 \
|
||||
-v voicebox-data:/app/data \
|
||||
voicebox:cuda
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
```
|
||||
|
||||
**Docker Compose with GPU:**
|
||||
```yaml
|
||||
services:
|
||||
voicebox:
|
||||
image: voicebox:cuda
|
||||
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
@@ -213,47 +116,9 @@ services:
|
||||
capabilities: [gpu]
|
||||
```
|
||||
|
||||
### AMD GPUs (ROCm) - Experimental
|
||||
### AMD GPUs (ROCm)
|
||||
|
||||
**Dockerfile:**
|
||||
```dockerfile
|
||||
FROM rocm/dev-ubuntu-22.04:6.0
|
||||
|
||||
# Install Python
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3.11 python3-pip git ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install PyTorch with ROCm support
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.0
|
||||
|
||||
# Install other dependencies
|
||||
RUN pip3 install -r requirements.txt
|
||||
RUN pip3 install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
|
||||
# Set ROCm environment variables
|
||||
ENV HSA_OVERRIDE_GFX_VERSION=10.3.0
|
||||
ENV ROCM_PATH=/opt/rocm
|
||||
|
||||
COPY backend/ /app/backend/
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
**Run with AMD GPU:**
|
||||
```bash
|
||||
docker run --device=/dev/kfd --device=/dev/dri \
|
||||
--group-add video --ipc=host --cap-add=SYS_PTRACE \
|
||||
--security-opt seccomp=unconfined \
|
||||
-p 8000:8000 -v voicebox-data:/app/data \
|
||||
voicebox:rocm
|
||||
```
|
||||
|
||||
**Note:** ROCm support varies by GPU model. Works best on Linux. See [AMD ROCm docs](https://rocm.docs.amd.com) for compatibility.
|
||||
ROCm support is not currently available in pre-built images. If you need ROCm, build a custom image using the ROCm base and PyTorch ROCm builds.
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
@@ -734,13 +599,27 @@ docker logs -f voicebox
|
||||
docker compose logs -f voicebox
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
## Updates
|
||||
|
||||
- [ ] Publish official images to GitHub Container Registry
|
||||
- [ ] Add Kubernetes Helm charts
|
||||
- [ ] Create Docker Desktop extension
|
||||
- [ ] Add automated vulnerability scanning
|
||||
- [ ] Support ARM64 builds for Raspberry Pi / Apple Silicon
|
||||
Docker images are automatically built and published on each GitHub release. To update:
|
||||
|
||||
```bash
|
||||
# Pull latest
|
||||
docker pull ghcr.io/jamiepine/voicebox:latest
|
||||
docker compose up -d
|
||||
|
||||
# Or pin to a specific version
|
||||
docker pull ghcr.io/jamiepine/voicebox:0.1.13
|
||||
```
|
||||
|
||||
For automatic updates, use [Watchtower](https://containrrr.dev/watchtower/).
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Kubernetes Helm charts
|
||||
- Docker Desktop extension
|
||||
- Automated vulnerability scanning
|
||||
- ROCm image variant
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -0,0 +1,974 @@
|
||||
# TTS Provider Architecture
|
||||
|
||||
**Status:** Planned for v0.1.13
|
||||
**Created:** 2025-01-31
|
||||
**Problem:** GitHub 2GB release limit + poor UX for frequent updates requiring 2.4GB re-downloads
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Split the monolithic backend into modular components:
|
||||
|
||||
1. **Main App**:
|
||||
- Windows/Linux (~150MB): Tauri + FastAPI backend + Whisper + UI/profiles/history
|
||||
- macOS (~300MB): Same + MLX bundled for simplicity
|
||||
2. **TTS Providers** (Windows/Linux only): Downloadable executables for PyTorch CPU/CUDA inference
|
||||
|
||||
This architecture solves:
|
||||
|
||||
- ✅ GitHub 2GB release artifact limit
|
||||
- ✅ Frequent app updates without re-downloading large python binaries (Windows/Linux)
|
||||
- ✅ User choice of compute backend (CPU/GPU/Cloud) on Windows/Linux
|
||||
- ✅ Simplified out-of-the-box experience on macOS
|
||||
- ✅ External provider support (OpenAI, custom servers)
|
||||
- ✅ Future extensibility
|
||||
|
||||
---
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
### Windows / Linux
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Voicebox App (Tauri + Backend) ~150MB │
|
||||
│ ├─ UI Layer (React) │
|
||||
│ ├─ Backend (FastAPI) │
|
||||
│ │ ├─ Voice Profiles │
|
||||
│ │ ├─ Generation History │
|
||||
│ │ ├─ Audio Editing / Stories │
|
||||
│ │ └─ Provider Manager ◄──────────────┐ │
|
||||
│ └─ Whisper (bundled, tiny ~50MB) │ │
|
||||
└─────────────────────────────────────────┼────────────────┘
|
||||
│
|
||||
HTTP/IPC │
|
||||
│
|
||||
┌─────────────────────┴─────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ TTS Provider: │ │ TTS Provider: │
|
||||
│ PyTorch CPU │ │ PyTorch CUDA │
|
||||
│ │ │ │
|
||||
│ ~300MB │ │ ~2.4GB │
|
||||
│ │ │ │
|
||||
│ Local inference │ │ GPU inference │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
│ │
|
||||
└───────────────┬───────────────────────┘
|
||||
│
|
||||
┌─────────────▼──────────────┐
|
||||
│ Future Providers: │
|
||||
│ • Remote Server │
|
||||
│ • OpenAI API │
|
||||
│ • ElevenLabs │
|
||||
│ • Custom Docker Container │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
### macOS
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Voicebox App (Tauri + Backend) ~300MB │
|
||||
│ ├─ UI Layer (React) │
|
||||
│ ├─ Backend (FastAPI) │
|
||||
│ │ ├─ Voice Profiles │
|
||||
│ │ ├─ Generation History │
|
||||
│ │ ├─ Audio Editing / Stories │
|
||||
│ │ └─ MLX Backend (bundled) │
|
||||
│ └─ Whisper (bundled, tiny ~50MB) │
|
||||
│ │
|
||||
│ No provider downloads needed - works out of the box │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### Current Architecture Issues
|
||||
|
||||
**Monolithic Binary:**
|
||||
|
||||
- CPU version: ~295MB
|
||||
- CUDA version: ~2.37GB
|
||||
- GitHub releases: 2GB file size limit (BLOCKED)
|
||||
- Updates require re-downloading entire binary
|
||||
- Poor UX: update app → restart → download CUDA update → restart again
|
||||
|
||||
**User Pain Points:**
|
||||
|
||||
1. Cannot release CUDA version on GitHub (over 2GB)
|
||||
2. Every app update forces 2.4GB re-download for GPU users
|
||||
3. No flexibility (can't use OpenAI, remote servers, etc.)
|
||||
4. Wastes bandwidth for small bug fixes
|
||||
|
||||
---
|
||||
|
||||
## Solution: Pluggable TTS Providers
|
||||
|
||||
### Component Breakdown
|
||||
|
||||
#### 1. Main App (voicebox.exe / .app / .AppImage)
|
||||
|
||||
**Windows/Linux Size:** ~100-150MB
|
||||
**macOS Size:** ~300-350MB (includes MLX)
|
||||
|
||||
**Includes:**
|
||||
|
||||
- Tauri runtime + React UI
|
||||
- FastAPI backend (pure Python, no PyTorch on Windows/Linux)
|
||||
- Whisper model (tiny, ~50MB)
|
||||
- SQLite database
|
||||
- Profile/history/audio editing logic
|
||||
- Provider management system (Windows/Linux only)
|
||||
- **MLX backend (macOS only, bundled)**
|
||||
|
||||
**Does NOT include (Windows/Linux only):**
|
||||
|
||||
- PyTorch (CPU or CUDA)
|
||||
- TTS models (Qwen3-TTS)
|
||||
- Heavy ML dependencies
|
||||
|
||||
**Updates frequently:** UI fixes, feature additions, non-ML changes
|
||||
|
||||
---
|
||||
|
||||
#### 2. TTS Provider: PyTorch CPU
|
||||
|
||||
**Binary:** `tts-provider-pytorch-cpu.exe`
|
||||
**Size:** ~200MB
|
||||
|
||||
**Includes:**
|
||||
|
||||
- PyTorch CPU build
|
||||
- Qwen3-TTS package
|
||||
- Transformers
|
||||
- No CUDA libraries
|
||||
|
||||
**Download source:** Cloudflare R2
|
||||
**Updates rarely:** Only when model code changes
|
||||
|
||||
---
|
||||
|
||||
#### 3. TTS Provider: PyTorch CUDA
|
||||
|
||||
**Binary:** `tts-provider-pytorch-cuda.exe`
|
||||
**Size:** ~2.4GB
|
||||
|
||||
**Includes:**
|
||||
|
||||
- PyTorch CUDA build (cu121)
|
||||
- Qwen3-TTS package
|
||||
- CUDA runtime, cuDNN, cuBLAS
|
||||
- Transformers
|
||||
|
||||
**Download source:** Cloudflare R2
|
||||
**Platform:** Windows + Linux (NVIDIA GPU)
|
||||
**Updates rarely:** Only when model code or CUDA version changes
|
||||
|
||||
---
|
||||
|
||||
#### 4. TTS Provider: Remote
|
||||
|
||||
**Binary:** None (built-in config)
|
||||
**Size:** 0MB
|
||||
|
||||
**How it works:**
|
||||
|
||||
- User provides URL to their own TTS server
|
||||
- Backend proxies requests to that server
|
||||
- Implements API spec from `EXTERNAL_PROVIDERS.md`
|
||||
|
||||
**Use cases:**
|
||||
|
||||
- AMD GPU users running their own server
|
||||
- Team deployments with shared GPU server
|
||||
- Cloud hosting (Modal, RunPod, Replicate)
|
||||
|
||||
---
|
||||
|
||||
#### 5. TTS Provider: OpenAI
|
||||
|
||||
**Binary:** None (API wrapper)
|
||||
**Size:** 0MB
|
||||
|
||||
**How it works:**
|
||||
|
||||
- User provides OpenAI API key
|
||||
- Backend wraps OpenAI Audio API
|
||||
- Voice profiles map to OpenAI voices
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Zero local compute
|
||||
- Pay-per-use
|
||||
- Instant setup
|
||||
|
||||
---
|
||||
|
||||
## Communication Protocol
|
||||
|
||||
### Provider API Specification
|
||||
|
||||
All TTS providers must implement these endpoints:
|
||||
|
||||
#### POST /tts/generate
|
||||
|
||||
Generate speech from text.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "Hello world!",
|
||||
"voice_prompt": {
|
||||
/* voice prompt object */
|
||||
},
|
||||
"language": "en",
|
||||
"seed": 12345,
|
||||
"model_size": "1.7B"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"audio": "base64-encoded-audio",
|
||||
"sample_rate": 24000,
|
||||
"duration": 2.5
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /tts/create_voice_prompt
|
||||
|
||||
Create voice prompt from reference audio.
|
||||
|
||||
**Request:** (multipart/form-data)
|
||||
|
||||
- `audio`: Audio file
|
||||
- `reference_text`: Transcript
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"voice_prompt": {
|
||||
/* serialized prompt */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /tts/health
|
||||
|
||||
Health check.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"provider": "pytorch-cuda",
|
||||
"version": "1.0.0",
|
||||
"model": "Qwen3-TTS-12Hz-1.7B-Base",
|
||||
"device": "cuda:0"
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /tts/status
|
||||
|
||||
Model status.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_loaded": true,
|
||||
"model_size": "1.7B",
|
||||
"available_sizes": ["0.6B", "1.7B"],
|
||||
"gpu_available": true,
|
||||
"vram_used_mb": 1234
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### Provider Manager
|
||||
|
||||
**File:** `backend/providers/__init__.py`
|
||||
|
||||
```python
|
||||
class ProviderManager:
|
||||
"""Manages TTS provider lifecycle (Windows/Linux only).
|
||||
|
||||
Note: macOS uses bundled MLX backend directly, no provider management needed.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.active_provider: Optional[Provider] = None
|
||||
self.config = load_provider_config()
|
||||
|
||||
async def start_provider(self, provider_type: str) -> str:
|
||||
"""Start a TTS provider process."""
|
||||
if provider_type == "pytorch-cpu":
|
||||
return await self._start_local_provider("tts-provider-pytorch-cpu.exe")
|
||||
elif provider_type == "pytorch-cuda":
|
||||
return await self._start_local_provider("tts-provider-pytorch-cuda.exe")
|
||||
elif provider_type == "remote":
|
||||
return self.config["remote_url"]
|
||||
elif provider_type == "openai":
|
||||
return None # No subprocess, API wrapper
|
||||
|
||||
async def _start_local_provider(self, binary_name: str) -> str:
|
||||
"""Start local provider subprocess."""
|
||||
provider_path = get_provider_binary_path(binary_name)
|
||||
|
||||
if not provider_path.exists():
|
||||
raise ProviderNotInstalledException(binary_name)
|
||||
|
||||
# Start subprocess on random port
|
||||
port = get_free_port()
|
||||
process = subprocess.Popen([
|
||||
str(provider_path),
|
||||
"--port", str(port),
|
||||
"--data-dir", str(config.get_data_dir())
|
||||
])
|
||||
|
||||
# Wait for provider to be ready
|
||||
await wait_for_provider_health(f"http://localhost:{port}")
|
||||
|
||||
self.active_provider = Provider(process, port)
|
||||
return f"http://localhost:{port}"
|
||||
|
||||
async def stop_provider(self):
|
||||
"""Stop active provider."""
|
||||
if self.active_provider:
|
||||
self.active_provider.process.terminate()
|
||||
self.active_provider = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Provider Abstraction
|
||||
|
||||
**File:** `backend/providers/base.py`
|
||||
|
||||
```python
|
||||
class TTSProvider(ABC):
|
||||
"""Abstract base for TTS providers."""
|
||||
|
||||
@abstractmethod
|
||||
async def generate(
|
||||
self,
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str,
|
||||
seed: Optional[int]
|
||||
) -> tuple[np.ndarray, int]:
|
||||
"""Generate speech audio."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def create_voice_prompt(
|
||||
self,
|
||||
audio_path: str,
|
||||
reference_text: str
|
||||
) -> dict:
|
||||
"""Create voice prompt from reference audio."""
|
||||
pass
|
||||
```
|
||||
|
||||
**File:** `backend/providers/local.py`
|
||||
|
||||
```python
|
||||
class LocalProvider(TTSProvider):
|
||||
"""Provider that communicates with local subprocess via HTTP."""
|
||||
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url
|
||||
self.client = httpx.AsyncClient()
|
||||
|
||||
async def generate(self, text, voice_prompt, language, seed):
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/tts/generate",
|
||||
json={
|
||||
"text": text,
|
||||
"voice_prompt": voice_prompt,
|
||||
"language": language,
|
||||
"seed": seed
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
audio = np.frombuffer(base64.b64decode(data["audio"]), dtype=np.float32)
|
||||
return audio, data["sample_rate"]
|
||||
```
|
||||
|
||||
**File:** `backend/providers/openai.py`
|
||||
|
||||
```python
|
||||
class OpenAIProvider(TTSProvider):
|
||||
"""Provider that wraps OpenAI Audio API."""
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
self.client = OpenAI(api_key=api_key)
|
||||
|
||||
async def generate(self, text, voice_prompt, language, seed):
|
||||
# Map voice_prompt to OpenAI voice name
|
||||
voice = map_profile_to_openai_voice(voice_prompt)
|
||||
|
||||
response = await self.client.audio.speech.create(
|
||||
model="tts-1",
|
||||
voice=voice,
|
||||
input=text
|
||||
)
|
||||
|
||||
# Convert to numpy array
|
||||
audio_data = response.content
|
||||
audio, sr = load_audio_from_bytes(audio_data)
|
||||
return audio, sr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Provider Installation
|
||||
|
||||
### Download Manager
|
||||
|
||||
**File:** `backend/providers/installer.py`
|
||||
|
||||
```python
|
||||
class ProviderInstaller:
|
||||
"""Handles provider download and installation (Windows/Linux only)."""
|
||||
|
||||
async def download_provider(self, provider_type: str):
|
||||
"""Download provider binary from R2."""
|
||||
|
||||
binary_name = {
|
||||
"pytorch-cpu": "tts-provider-pytorch-cpu.exe",
|
||||
"pytorch-cuda": "tts-provider-pytorch-cuda.exe"
|
||||
}[provider_type]
|
||||
|
||||
download_url = f"https://downloads.voicebox.sh/providers/v{PROVIDER_VERSION}/{binary_name}"
|
||||
|
||||
# Download with progress tracking (reuse existing SSE system)
|
||||
await download_with_progress(
|
||||
url=download_url,
|
||||
destination=get_provider_install_path(binary_name),
|
||||
progress_key=f"provider-{provider_type}"
|
||||
)
|
||||
```
|
||||
|
||||
**Provider Storage Location:**
|
||||
|
||||
- Windows: `%APPDATA%/voicebox/providers/`
|
||||
- macOS: `~/Library/Application Support/voicebox/providers/`
|
||||
- Linux: `~/.local/share/voicebox/providers/`
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### Provider Settings UI
|
||||
|
||||
**Component:** `app/src/components/ServerSettings/ProviderSettings.tsx`
|
||||
|
||||
```tsx
|
||||
export function ProviderSettings() {
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
useState<ProviderType>("auto");
|
||||
const {data: installedProviders} = useQuery({
|
||||
queryKey: ["providers", "installed"],
|
||||
queryFn: () => apiClient.getInstalledProviders(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>TTS Provider</CardTitle>
|
||||
<CardDescription>Choose how Voicebox generates speech</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup
|
||||
value={selectedProvider}
|
||||
onValueChange={setSelectedProvider}
|
||||
>
|
||||
{/* Auto-detect */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="auto" id="auto" />
|
||||
<Label htmlFor="auto">
|
||||
<div className="font-medium">Auto-detect (Recommended)</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Automatically choose the best available provider
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* PyTorch CUDA */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="pytorch-cuda"
|
||||
id="cuda"
|
||||
disabled={!gpuAvailable}
|
||||
/>
|
||||
<Label htmlFor="cuda">
|
||||
<div className="font-medium">PyTorch CUDA (NVIDIA GPU)</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
4-5x faster inference on NVIDIA GPUs
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{!installedProviders?.includes("pytorch-cuda") && gpuAvailable && (
|
||||
<Button
|
||||
onClick={() => downloadProvider("pytorch-cuda")}
|
||||
size="sm"
|
||||
>
|
||||
Download (2.4GB)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PyTorch CPU (Windows/Linux only) */}
|
||||
{!isMacOS && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="pytorch-cpu" id="cpu" />
|
||||
<Label htmlFor="cpu">
|
||||
<div className="font-medium">PyTorch CPU</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Works on any system, slower inference
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{!installedProviders?.includes("pytorch-cpu") && (
|
||||
<Button onClick={() => downloadProvider("pytorch-cpu")} size="sm">
|
||||
Download (300MB)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MLX bundled (macOS only) */}
|
||||
{isMacOS && (
|
||||
<div className="p-3 bg-muted rounded-md">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">MLX (Apple Silicon)</div>
|
||||
<div className="text-muted-foreground mt-1">
|
||||
Bundled with the app - optimized for M1/M2/M3 chips
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="remote" id="remote" />
|
||||
<Label htmlFor="remote">
|
||||
<div className="font-medium">Remote Server</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Connect to your own TTS server
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{selectedProvider === "remote" && (
|
||||
<Input placeholder="http://your-server:8000" className="ml-6" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OpenAI */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="openai" id="openai" />
|
||||
<Label htmlFor="openai">
|
||||
<div className="font-medium">OpenAI API</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Use OpenAI's TTS API (requires API key)
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{selectedProvider === "openai" && (
|
||||
<Input type="password" placeholder="sk-..." className="ml-6" />
|
||||
)}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
voicebox/
|
||||
├── backend/
|
||||
│ ├── main.py # Main FastAPI app (no TTS on Win/Linux)
|
||||
│ ├── backends/
|
||||
│ │ ├── __init__.py # Backend abstraction (existing)
|
||||
│ │ ├── pytorch_backend.py # PyTorch backend (existing, for reference)
|
||||
│ │ └── mlx_backend.py # MLX backend (bundled in macOS build only)
|
||||
│ ├── providers/
|
||||
│ │ ├── __init__.py # ProviderManager (Windows/Linux)
|
||||
│ │ ├── base.py # TTSProvider Protocol
|
||||
│ │ ├── local.py # LocalProvider (subprocess)
|
||||
│ │ ├── remote.py # RemoteProvider (HTTP)
|
||||
│ │ ├── openai.py # OpenAIProvider (API wrapper)
|
||||
│ │ └── installer.py # Provider download logic (Windows/Linux)
|
||||
│ ├── profiles.py # Voice profile management
|
||||
│ ├── history.py # Generation history
|
||||
│ ├── transcribe.py # Whisper (still bundled)
|
||||
│ └── ... (other backend modules)
|
||||
│
|
||||
├── providers/
|
||||
│ ├── pytorch-cpu/
|
||||
│ │ ├── main.py # FastAPI server for TTS
|
||||
│ │ ├── tts_backend.py # PyTorch TTS logic
|
||||
│ │ ├── requirements.txt # torch (CPU), qwen-tts, transformers
|
||||
│ │ └── build.spec # PyInstaller spec
|
||||
│ │
|
||||
│ └── pytorch-cuda/
|
||||
│ ├── main.py # FastAPI server for TTS
|
||||
│ ├── tts_backend.py # PyTorch TTS logic
|
||||
│ ├── requirements.txt # torch+cu121, qwen-tts, transformers
|
||||
│ └── build.spec # PyInstaller spec
|
||||
│
|
||||
├── app/ # Frontend (Tauri + React)
|
||||
│ └── src/
|
||||
│ └── components/
|
||||
│ └── ServerSettings/
|
||||
│ └── ProviderSettings.tsx # Only shown on Windows/Linux
|
||||
│
|
||||
└── tauri/
|
||||
└── src-tauri/
|
||||
└── tauri.conf.json # No externalBin for providers (Windows/Linux)
|
||||
# MLX bundled in macOS build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Refactor Backend (No User Changes)
|
||||
|
||||
**Goal:** Abstract TTS behind provider interface
|
||||
|
||||
1. Create `backend/providers/` module structure
|
||||
2. Implement `TTSProvider` abstract base class
|
||||
3. Create `LocalProvider` wrapper for current PyTorch code
|
||||
4. Modify `backend/tts.py` to use provider abstraction
|
||||
5. Keep PyTorch bundled in main app
|
||||
|
||||
**Result:** Code is prepared, but user experience unchanged
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Build Provider Binaries
|
||||
|
||||
**Goal:** Create standalone TTS provider executables (Windows/Linux only)
|
||||
|
||||
1. Create separate PyInstaller specs for each provider
|
||||
2. Build provider executables:
|
||||
- `tts-provider-pytorch-cpu.exe` (~300MB)
|
||||
- `tts-provider-pytorch-cuda.exe` (~2.4GB)
|
||||
3. Test subprocess communication
|
||||
4. Upload providers to Cloudflare R2
|
||||
|
||||
**Result:** Provider binaries exist but aren't used yet
|
||||
|
||||
**Note:** macOS keeps MLX bundled in main app - no separate provider needed
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Remove PyTorch from Main App
|
||||
|
||||
**Goal:** Split main app from providers (Windows/Linux only)
|
||||
|
||||
1. Exclude PyTorch/Qwen3-TTS from Windows/Linux main app PyInstaller spec
|
||||
2. Windows/Linux app now requires provider download
|
||||
3. Update GitHub CI to build multiple artifacts:
|
||||
- `voicebox-{version}-windows.exe` (~150MB, no TTS)
|
||||
- `voicebox-{version}-linux.AppImage` (~150MB, no TTS)
|
||||
- `voicebox-{version}-macos.app` (~300MB, MLX bundled)
|
||||
- `tts-provider-pytorch-cpu-{version}.exe`
|
||||
- `tts-provider-pytorch-cuda-{version}.exe`
|
||||
|
||||
**Result:** Windows/Linux apps are small with downloadable providers, macOS app is self-contained
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Add Provider UI
|
||||
|
||||
**Goal:** User-facing provider management
|
||||
|
||||
1. Create Provider Settings page
|
||||
2. Implement provider download UI
|
||||
3. Add provider status indicators
|
||||
4. Show active provider in UI
|
||||
|
||||
**Result:** Users can choose and download providers
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: External Providers
|
||||
|
||||
**Goal:** Enable remote and cloud providers
|
||||
|
||||
1. Implement `RemoteProvider` (HTTP client)
|
||||
2. Implement `OpenAIProvider` (API wrapper)
|
||||
3. Add provider configuration UI (URLs, API keys)
|
||||
4. Document external provider API spec
|
||||
|
||||
**Result:** Full provider ecosystem
|
||||
|
||||
---
|
||||
|
||||
## Provider Versioning
|
||||
|
||||
### Independent Versioning
|
||||
|
||||
Providers have their own version numbers, independent of the main app:
|
||||
|
||||
- **App version:** `v0.2.0` (frequent updates)
|
||||
- **Provider version:** `v1.0.0` (rare updates)
|
||||
|
||||
### Compatibility Matrix
|
||||
|
||||
**Example:**
|
||||
|
||||
| App Version | Min Provider Version | Max Provider Version |
|
||||
| ----------- | -------------------- | -------------------- |
|
||||
| v0.2.0 | v1.0.0 | v1.x.x |
|
||||
| v0.3.0 | v1.0.0 | v1.x.x |
|
||||
| v0.4.0 | v1.2.0 | v1.x.x |
|
||||
| v1.0.0 | v2.0.0 | v2.x.x |
|
||||
|
||||
**Backend checks compatibility:**
|
||||
|
||||
```python
|
||||
async def check_provider_compatibility(provider_version: str) -> bool:
|
||||
"""Check if provider version is compatible with current app."""
|
||||
min_version = "1.0.0"
|
||||
max_version = "1.999.999"
|
||||
return min_version <= provider_version < max_version
|
||||
```
|
||||
|
||||
**UI shows warning if incompatible:**
|
||||
|
||||
```
|
||||
⚠️ Provider version 0.9.0 is outdated. Update to v1.0.0+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Flows
|
||||
|
||||
### First-Time Setup (Windows/Linux)
|
||||
|
||||
1. User downloads and installs Voicebox (~150MB)
|
||||
2. App launches → detects no TTS provider installed
|
||||
3. Shows setup wizard:
|
||||
|
||||
```
|
||||
Choose your TTS provider:
|
||||
|
||||
[ ] PyTorch CUDA (2.4GB) [Download]
|
||||
✓ Fastest on NVIDIA GPUs
|
||||
✗ Requires NVIDIA GPU
|
||||
|
||||
[●] PyTorch CPU (300MB) [Download]
|
||||
✓ Works on any system
|
||||
✗ Slower inference
|
||||
|
||||
[ ] Remote Server
|
||||
URL: ___________________
|
||||
|
||||
[ ] OpenAI API
|
||||
API Key: ________________
|
||||
```
|
||||
|
||||
4. User selects provider → downloads with progress bar
|
||||
5. Provider installs to AppData/Application Support
|
||||
6. App starts provider → ready to use
|
||||
|
||||
### First-Time Setup (macOS)
|
||||
|
||||
1. User downloads and installs Voicebox (~300MB with MLX bundled)
|
||||
2. App launches → MLX backend is ready immediately
|
||||
3. No provider setup needed - works out of the box
|
||||
|
||||
---
|
||||
|
||||
### App Update Flow (No Provider Change)
|
||||
|
||||
**Scenario:** Bug fix in UI, no backend changes
|
||||
|
||||
**Windows/Linux:**
|
||||
1. User gets update notification: "Voicebox v0.2.1 available"
|
||||
2. Downloads update (~150MB, not 2.4GB!)
|
||||
3. Installs and restarts
|
||||
4. **Provider stays the same** (no re-download needed)
|
||||
5. App starts using existing provider
|
||||
|
||||
**macOS:**
|
||||
1. User gets update notification: "Voicebox v0.2.1 available"
|
||||
2. Downloads update (~300MB with MLX bundled)
|
||||
3. Installs and restarts - ready to use
|
||||
|
||||
**User experience:** Fast updates, no multi-GB downloads (especially for CUDA users)
|
||||
|
||||
---
|
||||
|
||||
### Provider Update Flow
|
||||
|
||||
**Scenario:** New Qwen3-TTS model version released
|
||||
|
||||
1. User opens Settings → Provider tab
|
||||
2. Sees notification: "Provider update available (v1.1.0)"
|
||||
3. Clicks "Update Provider"
|
||||
4. Downloads new provider binary
|
||||
5. Old provider binary is replaced
|
||||
6. Restart app to use new provider
|
||||
|
||||
**Frequency:** Rare (only when TTS model/backend changes)
|
||||
|
||||
---
|
||||
|
||||
### Switching Providers
|
||||
|
||||
**Scenario:** User upgrades to NVIDIA GPU
|
||||
|
||||
1. User goes to Settings → Provider
|
||||
2. Selects "PyTorch CUDA"
|
||||
3. Clicks "Download" → downloads 2.4GB
|
||||
4. Download completes → restarts app
|
||||
5. App now uses CUDA provider
|
||||
|
||||
---
|
||||
|
||||
## Benefits
|
||||
|
||||
| Benefit | Details |
|
||||
| ----------------------------- | --------------------------------------------------------- |
|
||||
| **GitHub Releases Work** | Main app ~150MB (Win/Linux), ~300MB (macOS) << 2GB limit |
|
||||
| **Fast Updates** | UI/feature updates don't require re-downloading providers |
|
||||
| **User Choice** | CPU, CUDA, OpenAI, remote server (Win/Linux) |
|
||||
| **macOS Simplicity** | MLX bundled - works immediately, no provider setup needed |
|
||||
| **External Provider Support** | Users can run their own TTS servers |
|
||||
| **Bandwidth Savings** | Only download provider once, app updates are small |
|
||||
| **Future-Proof** | Easy to add new providers (ElevenLabs, custom models) |
|
||||
| **Team Deployments** | Multiple users share one remote provider |
|
||||
| **Cloud-Ready** | Works with Modal, Replicate, RunPod, etc. |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
### 1. Provider Versioning
|
||||
|
||||
**Question:** Should providers have independent versions or match app version?
|
||||
|
||||
**Options:**
|
||||
|
||||
- A. Independent (providers: v1.x, app: v0.2.x)
|
||||
- B. Matched (both use v0.2.x)
|
||||
|
||||
**Recommendation:** Independent versioning with compatibility matrix
|
||||
|
||||
---
|
||||
|
||||
### 2. Auto-Update Providers
|
||||
|
||||
**Question:** Should providers auto-update separately from app?
|
||||
|
||||
**Options:**
|
||||
|
||||
- A. Manual updates only (user clicks "Update Provider")
|
||||
- B. Optional auto-update (user can enable)
|
||||
- C. Always auto-update
|
||||
|
||||
**Recommendation:** Optional auto-update (default off)
|
||||
|
||||
---
|
||||
|
||||
### 3. Provider Discovery
|
||||
|
||||
**Question:** How does app find installed providers?
|
||||
|
||||
**Options:**
|
||||
|
||||
- A. Check standard paths in AppData/Application Support
|
||||
- B. Registry (Windows) / plist (macOS)
|
||||
- C. Config file with provider locations
|
||||
|
||||
**Recommendation:** Standard paths + config fallback
|
||||
|
||||
---
|
||||
|
||||
### 4. Fallback Behavior
|
||||
|
||||
**Question:** What if no provider is installed?
|
||||
|
||||
**Options:**
|
||||
|
||||
- A. Show setup wizard on first launch
|
||||
- B. Block app until provider installed
|
||||
- C. Allow app to run in "demo mode" (transcription only)
|
||||
|
||||
**Recommendation:** Setup wizard on first launch
|
||||
|
||||
---
|
||||
|
||||
### 5. Provider Auto-Start
|
||||
|
||||
**Question:** Should provider start automatically with app?
|
||||
|
||||
**Options:**
|
||||
|
||||
- A. Always start selected provider on app launch
|
||||
- B. Start on-demand (when user generates speech)
|
||||
- C. User preference
|
||||
|
||||
**Recommendation:** Auto-start (configurable in settings)
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] **Provider Marketplace:** Built-in directory of community providers
|
||||
- [ ] **Multi-Provider Support:** Use different providers per voice/language
|
||||
- [ ] **Provider Health Monitoring:** Automatic failover if provider crashes
|
||||
- [ ] **Cost Tracking:** Monitor API usage for OpenAI/cloud providers
|
||||
- [ ] **Performance Metrics:** Latency, throughput, VRAM usage dashboards
|
||||
- [ ] **Docker Providers:** Run providers in Docker containers
|
||||
- [ ] **Provider Plugins:** Load custom providers from user scripts
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [EXTERNAL_PROVIDERS.md](./EXTERNAL_PROVIDERS.md) - External provider support plan
|
||||
- [OPENAI_SUPPORT.md](./OPENAI_SUPPORT.md) - OpenAI API compatibility
|
||||
- [github-2gb-limit-issue.md](../github-2gb-limit-issue.md) - Original problem
|
||||
- [r2-setup.md](../r2-setup.md) - Cloudflare R2 configuration
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
If you want to build a custom TTS provider:
|
||||
|
||||
1. Implement the provider API spec (see above)
|
||||
2. Test with Voicebox locally
|
||||
3. Package as executable (PyInstaller, Docker, etc.)
|
||||
4. Share in GitHub Discussions
|
||||
|
||||
**Questions?**
|
||||
|
||||
- GitHub Issues: [voicebox/issues](https://github.com/jamiepine/voicebox/issues)
|
||||
- Discord: Coming soon
|
||||
@@ -0,0 +1,94 @@
|
||||
# Documentation Migration: Mintlify → Fumadocs
|
||||
|
||||
This document summarizes the migration of documentation from `/docs` (Mintlify) to `/docs2` (Fumadocs).
|
||||
|
||||
## What Was Done
|
||||
|
||||
### 1. Files Copied
|
||||
|
||||
- ✅ All 29 MDX files from `/docs` folders (overview, api, developer, plans)
|
||||
- ✅ All 4 root-level markdown files (AUTOUPDATER.md, AUTOUPDATER_QUICKSTART.md, TROUBLESHOOTING.md, README.md)
|
||||
- ✅ All images (3 webp files) → `public/images/`
|
||||
- ✅ All logo files (2 png files) → `public/logo/`
|
||||
|
||||
### 2. Component Migration
|
||||
|
||||
Created compatibility layer in `components/mintlify-compat.tsx` that maps Mintlify components to Fumadocs equivalents:
|
||||
|
||||
- `<Frame>` → Simple div wrapper (images are zoomable by default in Fumadocs)
|
||||
- `<CardGroup>` → `<Cards>` (Fumadocs component)
|
||||
- `<Card>` → `<Card>` (with icon string → Lucide icon mapping)
|
||||
- `<Steps>` / `<Step>` → Direct mapping to Fumadocs components
|
||||
- `<Tip>`, `<Note>`, `<Info>` → `<Callout type="info">`
|
||||
- `<Warning>` → `<Callout type="warn">`
|
||||
- `<Danger>` → `<Callout type="error">`
|
||||
- `<AccordionGroup>` / `<Accordion>` → HTML `<details>` / `<summary>` elements
|
||||
|
||||
### 3. Navigation Structure
|
||||
|
||||
Created `meta.json` files for each folder:
|
||||
|
||||
- `content/docs/meta.json` - Root documentation
|
||||
- `content/docs/overview/meta.json` - Overview pages
|
||||
- `content/docs/api/meta.json` - API reference
|
||||
- `content/docs/developer/meta.json` - Developer docs
|
||||
- `content/docs/plans/meta.json` - Plans/roadmap
|
||||
|
||||
### 4. Link Fixes
|
||||
|
||||
- Fixed incorrect `/guides/...` paths → `/overview/...`
|
||||
- All internal links now use correct paths
|
||||
|
||||
### 5. Branding
|
||||
|
||||
- Updated `lib/layout.shared.tsx` to use "Voicebox" as the nav title
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
docs2/
|
||||
├── components/
|
||||
│ └── mintlify-compat.tsx # Mintlify → Fumadocs component mappings
|
||||
├── content/docs/
|
||||
│ ├── meta.json # Root navigation
|
||||
│ ├── overview/ # 12 MDX files
|
||||
│ ├── api/ # 5 MDX files
|
||||
│ ├── developer/ # 12 MDX files
|
||||
│ ├── plans/ # 4 MD files
|
||||
│ └── *.md # 4 root markdown files
|
||||
├── public/
|
||||
│ ├── images/ # 3 webp files
|
||||
│ └── logo/ # 2 png files
|
||||
└── mdx-components.tsx # MDX component configuration
|
||||
```
|
||||
|
||||
## Icon Mapping
|
||||
|
||||
The following icon strings are mapped to Lucide icons:
|
||||
|
||||
- `microphone` → Mic
|
||||
- `film` → Film
|
||||
- `code` → Code
|
||||
- `shield` → Shield
|
||||
- `download` → Download
|
||||
- `rocket` → Rocket
|
||||
- `apple` → Apple
|
||||
- `windows` → Windows
|
||||
- `server` → Server
|
||||
- `user` → User
|
||||
- `waveform` → Waveform
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Test the build**: Run `npm run build` (requires Node.js >= 20.9.0)
|
||||
2. **Start dev server**: Run `npm run dev` to preview
|
||||
3. **Customize styling**: Update `app/global.css` if needed
|
||||
4. **Add more icons**: Extend `iconMap` in `mintlify-compat.tsx` as needed
|
||||
5. **Review navigation**: Adjust `meta.json` files to customize page order
|
||||
|
||||
## Notes
|
||||
|
||||
- Image paths (`/images/...`) work as-is since Next.js serves from `public/`
|
||||
- All Mintlify components are now compatible with Fumadocs
|
||||
- Navigation structure follows Fumadocs conventions
|
||||
- No breaking changes to content - all MDX files work with compatibility layer
|
||||
@@ -0,0 +1,203 @@
|
||||
---
|
||||
title: "Auto-Updater Documentation"
|
||||
description: "How Voicebox automatic updates work for users and developers"
|
||||
---
|
||||
|
||||
Voicebox includes automatic updates powered by Tauri's updater plugin. This document explains how it works for both users and developers.
|
||||
|
||||
## 1. Generate Signing Keys
|
||||
|
||||
Run this command to generate your signing keypair:
|
||||
|
||||
```bash
|
||||
cd tauri && bun tauri signer generate -w ~/.tauri/voicebox.key
|
||||
```
|
||||
|
||||
This creates:
|
||||
|
||||
- **Private key**: `~/.tauri/voicebox.key` (keep this secret!)
|
||||
- **Public key**: `~/.tauri/voicebox.key.pub`
|
||||
|
||||
## 2. Update Configuration
|
||||
|
||||
Copy the content from `~/.tauri/voicebox.key.pub` and replace the placeholder in `tauri/src-tauri/tauri.conf.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "PASTE_PUBLIC_KEY_CONTENT_HERE",
|
||||
"endpoints": [
|
||||
"https://github.com/YOUR_USERNAME/voicebox/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Update the endpoint URL with your actual GitHub username/organization.
|
||||
|
||||
## 3. Building with Signatures
|
||||
|
||||
When building releases, set these environment variables:
|
||||
|
||||
**macOS/Linux:**
|
||||
|
||||
```bash
|
||||
export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/voicebox.key)"
|
||||
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
|
||||
bun run build
|
||||
```
|
||||
|
||||
**Windows PowerShell:**
|
||||
|
||||
```powershell
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY = Get-Content ~/.tauri/voicebox.key -Raw
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = ""
|
||||
bun run build
|
||||
```
|
||||
|
||||
## 4. GitHub Release Setup
|
||||
|
||||
When you create a GitHub release, the build process will generate:
|
||||
|
||||
- Installers for each platform
|
||||
- `.sig` signature files
|
||||
- `latest.json` update manifest
|
||||
|
||||
### Manual Release Process
|
||||
|
||||
1. Build the app with signing keys set
|
||||
2. Create a new GitHub release
|
||||
3. Upload all files from `tauri/src-tauri/target/release/bundle/`
|
||||
4. Create `latest.json` in your release assets:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"notes": "Bug fixes and improvements",
|
||||
"pub_date": "2026-01-25T12:00:00Z",
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
"signature": "CONTENT_FROM_.app.tar.gz.sig",
|
||||
"url": "https://github.com/YOUR_USERNAME/voicebox/releases/download/v0.2.0/voicebox_0.2.0_aarch64.dmg"
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
"signature": "CONTENT_FROM_.app.tar.gz.sig",
|
||||
"url": "https://github.com/YOUR_USERNAME/voicebox/releases/download/v0.2.0/voicebox_0.2.0_x64.dmg"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"signature": "CONTENT_FROM_.AppImage.sig",
|
||||
"url": "https://github.com/YOUR_USERNAME/voicebox/releases/download/v0.2.0/voicebox_0.2.0_amd64.AppImage"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": "CONTENT_FROM_.msi.sig",
|
||||
"url": "https://github.com/YOUR_USERNAME/voicebox/releases/download/v0.2.0/voicebox_0.2.0_x64_en-US.msi"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Automated GitHub Actions (Recommended)
|
||||
|
||||
Create `.github/workflows/release.yml`:
|
||||
|
||||
```yaml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [macos-latest, ubuntu-22.04, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install dependencies (Ubuntu)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: bun run build
|
||||
|
||||
- name: Upload Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: tauri/src-tauri/target/release/bundle/**/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
Add your private key to GitHub secrets:
|
||||
|
||||
- Go to Settings → Secrets and variables → Actions
|
||||
- Add `TAURI_SIGNING_PRIVATE_KEY` with the content of `~/.tauri/voicebox.key`
|
||||
- Add `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` (empty string if no password)
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
The frontend integration is complete with automatic update notifications and manual update checks:
|
||||
|
||||
- **Update Notification Banner** - Appears automatically when updates are available
|
||||
- **Settings Panel** - Manual "Check for Updates" button in Settings tab
|
||||
- **Update Hook** - React hook handles all update operations
|
||||
|
||||
See `docs/AUTOUPDATER_QUICKSTART.md` for a quick setup guide.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Never commit your private key to version control
|
||||
- Store private keys securely (use GitHub secrets for CI/CD)
|
||||
- The public key in `tauri.conf.json` is safe to commit
|
||||
- Updates are cryptographically verified before installation
|
||||
- HTTP endpoints are blocked by default (HTTPS only)
|
||||
|
||||
## Testing Updates
|
||||
|
||||
1. Build version 0.1.0 and install it
|
||||
2. Update version in `tauri.conf.json` to 0.2.0
|
||||
3. Build version 0.2.0 with signatures
|
||||
4. Create a local server or GitHub release with `latest.json`
|
||||
5. Run version 0.1.0 and trigger update check
|
||||
6. Verify update downloads and installs correctly
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Invalid signature" error:**
|
||||
|
||||
- Verify public key matches the private key used to sign
|
||||
- Ensure signature files (.sig) are uploaded correctly
|
||||
|
||||
**"No update available" when one exists:**
|
||||
|
||||
- Check endpoint URL is correct
|
||||
- Verify `latest.json` format matches specification
|
||||
- Ensure version in latest.json is higher than current version
|
||||
|
||||
**Build fails with signing:**
|
||||
|
||||
- Confirm environment variables are set correctly
|
||||
- Check private key file exists and is readable
|
||||
- Verify private key format (should start with `dW50cnVzdGVkIGNvbW1lbnQ6`)
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
title: "Autoupdater Quick Start"
|
||||
description: "Quick guide to activate the Tauri v2 autoupdater"
|
||||
---
|
||||
|
||||
The Tauri v2 autoupdater has been fully configured and integrated. Follow these steps to activate it.
|
||||
|
||||
## What's Already Done
|
||||
|
||||
✅ Rust plugin installed and initialized
|
||||
✅ Tauri configuration set up with updater settings
|
||||
✅ Permissions granted for update operations
|
||||
✅ GitHub Actions workflow updated with signing support
|
||||
✅ Frontend components created and integrated
|
||||
✅ Update notifications on app startup
|
||||
✅ Manual update check in Settings tab
|
||||
|
||||
## Required Steps (5 minutes)
|
||||
|
||||
### 1. Generate Signing Keys
|
||||
|
||||
```bash
|
||||
bun run generate:keys
|
||||
```
|
||||
|
||||
This creates:
|
||||
|
||||
- Private key: `~/.tauri/voicebox.key` (keep secret!)
|
||||
- Public key: `~/.tauri/voicebox.key.pub` (safe to share)
|
||||
|
||||
### 2. Update Tauri Config
|
||||
|
||||
Open `tauri/src-tauri/tauri.conf.json` and:
|
||||
|
||||
1. Replace `"REPLACE_WITH_YOUR_PUBLIC_KEY"` with the content from `~/.tauri/voicebox.key.pub`
|
||||
2. Update the endpoint URL with your GitHub username:
|
||||
```json
|
||||
"endpoints": [
|
||||
"https://github.com/YOUR_USERNAME/voicebox/releases/latest/download/latest.json"
|
||||
]
|
||||
```
|
||||
|
||||
### 3. Add GitHub Secrets
|
||||
|
||||
Go to your repo Settings → Secrets and variables → Actions:
|
||||
|
||||
1. Add `TAURI_SIGNING_PRIVATE_KEY`:
|
||||
|
||||
```bash
|
||||
cat ~/.tauri/voicebox.key
|
||||
```
|
||||
|
||||
Copy the entire output and paste as the secret value
|
||||
|
||||
2. Add `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`:
|
||||
Leave empty (or add your password if you set one)
|
||||
|
||||
### 4. Test the Setup
|
||||
|
||||
To test locally before creating a release:
|
||||
|
||||
```bash
|
||||
bun run build:release
|
||||
```
|
||||
|
||||
This will verify your keys are set up correctly.
|
||||
|
||||
## How It Works
|
||||
|
||||
### For Users
|
||||
|
||||
1. App checks for updates on startup (only in Tauri builds)
|
||||
2. If an update is available, a banner appears at the top
|
||||
3. Users can click "Install Now" to download and install
|
||||
4. App restarts automatically after installation
|
||||
|
||||
### For Developers
|
||||
|
||||
1. Create a new git tag: `git tag v0.2.0 && git push --tags`
|
||||
2. GitHub Actions builds signed releases for all platforms
|
||||
3. Uploads installers and generates `latest.json` manifest
|
||||
4. Users running older versions will be notified automatically
|
||||
|
||||
## UI Components
|
||||
|
||||
### Update Notification Banner
|
||||
|
||||
- Shows at top of app when update is available
|
||||
- Appears automatically on startup
|
||||
- Displays download/install progress
|
||||
|
||||
### Settings Panel
|
||||
|
||||
- Located in Settings tab
|
||||
- Shows current version
|
||||
- Manual "Check for Updates" button
|
||||
- Update status and progress
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Public key not configured"**
|
||||
|
||||
- Make sure you copied the entire content from `voicebox.key.pub`
|
||||
- The key should start with `dW50cnVzdGVkIGNvbW1lbnQ6`
|
||||
|
||||
**"Failed to check for updates"**
|
||||
|
||||
- Endpoint URL might be incorrect
|
||||
- No releases published yet (expected for first setup)
|
||||
|
||||
**Build fails with signing error**
|
||||
|
||||
- Check that GitHub secrets are set correctly
|
||||
- Verify private key file exists at `~/.tauri/voicebox.key`
|
||||
|
||||
## Next Release Workflow
|
||||
|
||||
1. Update version in `tauri/src-tauri/tauri.conf.json`
|
||||
2. Commit changes
|
||||
3. Create and push tag: `git tag v0.2.0 && git push --tags`
|
||||
4. GitHub Actions will automatically build and create a draft release
|
||||
5. Review the release and publish it
|
||||
6. Users will be notified of the update
|
||||
|
||||
## See Also
|
||||
|
||||
- Full documentation: `docs/AUTOUPDATER.md`
|
||||
- Build script: `scripts/prepare-release.sh`
|
||||
- GitHub workflow: `.github/workflows/release.yml`
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Documentation README"
|
||||
description: "Voicebox documentation development guide"
|
||||
---
|
||||
|
||||
This directory contains the documentation for Voicebox, built with [Fumadocs](https://fumadocs.dev).
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Install Mintlify globally using bun:
|
||||
|
||||
```bash
|
||||
bun add -g mintlify
|
||||
```
|
||||
|
||||
Or use the helper script:
|
||||
|
||||
```bash
|
||||
bun run install:mintlify
|
||||
```
|
||||
|
||||
### Running Locally
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
This will start the Mintlify dev server.
|
||||
|
||||
The docs will be available at `http://localhost:3000`
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── mint.json # Mintlify configuration
|
||||
├── custom.css # Custom styles
|
||||
├── overview/ # Getting started & feature docs
|
||||
├── guides/ # User guides
|
||||
├── api/ # API reference
|
||||
├── development/ # Developer documentation
|
||||
├── logo/ # Logo assets
|
||||
└── public/ # Static assets
|
||||
```
|
||||
|
||||
### Writing Docs
|
||||
|
||||
- Use `.mdx` files for all documentation pages
|
||||
- Follow the existing structure in `mint.json` for navigation
|
||||
- Use Mintlify components for enhanced formatting (Card, CardGroup, Accordion, etc.)
|
||||
- Reference the [Mintlify documentation](https://mintlify.com/docs) for available components
|
||||
|
||||
## Deployment
|
||||
|
||||
Docs are automatically deployed when changes are pushed to the main branch.
|
||||
|
||||
To manually deploy:
|
||||
|
||||
```bash
|
||||
mintlify deploy
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](../CONTRIBUTING.md) for contribution guidelines.
|
||||
@@ -0,0 +1,360 @@
|
||||
---
|
||||
title: "Troubleshooting Guide"
|
||||
description: "Common issues and solutions for Voicebox"
|
||||
---
|
||||
|
||||
Common issues and solutions for Voicebox.
|
||||
|
||||
## Installation Issues
|
||||
|
||||
### macOS: "Voicebox cannot be opened because it is from an unidentified developer"
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Right-click the `.dmg` file
|
||||
2. Select "Open"
|
||||
3. Click "Open" in the security dialog
|
||||
4. Alternatively, go to System Settings → Privacy & Security → Allow Voicebox
|
||||
|
||||
### Windows: "Windows protected your PC"
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Click "More info"
|
||||
2. Click "Run anyway"
|
||||
3. Windows Defender may flag new software; this is normal for unsigned apps
|
||||
|
||||
### Linux: AppImage won't run
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
chmod +x voicebox-*.AppImage
|
||||
./voicebox-*.AppImage
|
||||
```
|
||||
|
||||
## Runtime Issues
|
||||
|
||||
### Server won't start
|
||||
|
||||
**Symptoms:** App opens but shows "Server not connected"
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check Python installation**
|
||||
|
||||
```bash
|
||||
python --version # Should be 3.11+
|
||||
```
|
||||
|
||||
2. **Check server binary exists**
|
||||
|
||||
- Look in `tauri/src-tauri/binaries/` for your platform
|
||||
- Binary should match your system architecture
|
||||
|
||||
3. **Check permissions**
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
chmod +x tauri/src-tauri/binaries/voicebox-server-*
|
||||
```
|
||||
|
||||
4. **Check logs**
|
||||
- macOS: Open Console.app and search for "voicebox"
|
||||
- Linux: Check `~/.local/share/voicebox/` for logs
|
||||
- Windows: Check Event Viewer
|
||||
|
||||
### "Model download failed"
|
||||
|
||||
**Symptoms:** First generation fails with download error
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check internet connection**
|
||||
|
||||
- Models download from HuggingFace Hub (~2-4GB)
|
||||
- First download may take several minutes
|
||||
|
||||
2. **Check disk space**
|
||||
|
||||
- Models are cached in `~/.cache/huggingface/`
|
||||
- Ensure at least 5GB free space
|
||||
|
||||
3. **Manual download** (if automatic fails)
|
||||
```bash
|
||||
pip install huggingface_hub
|
||||
huggingface-cli download Qwen/Qwen3-TTS-12Hz-1.7B-Base
|
||||
```
|
||||
|
||||
### "Out of memory" errors
|
||||
|
||||
**Symptoms:** Generation fails with CUDA/VRAM errors
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Use smaller model**
|
||||
|
||||
- Switch to 0.6B model instead of 1.7B
|
||||
- Settings → Model Management → Load 0.6B
|
||||
|
||||
2. **Close other applications**
|
||||
|
||||
- Free up GPU memory
|
||||
- Close browser tabs, other ML apps
|
||||
|
||||
3. **Use CPU mode**
|
||||
- Slower but works without GPU
|
||||
- Backend automatically falls back to CPU
|
||||
|
||||
### MLX "Failed to load the default metallib" error (Apple Silicon)
|
||||
|
||||
**Symptoms:** Generation fails with "library not found" or "metallib" errors
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Rebuild server binary**
|
||||
|
||||
```bash
|
||||
bun run build:server
|
||||
```
|
||||
|
||||
The build script should automatically include MLX Metal shader libraries.
|
||||
|
||||
2. **Check MLX installation**
|
||||
|
||||
```bash
|
||||
pip install -r backend/requirements-mlx.txt
|
||||
```
|
||||
|
||||
3. **Verify backend detection**
|
||||
- Check server logs for "Backend: MLX"
|
||||
- If showing "Backend: PYTORCH", MLX may not be installed correctly
|
||||
|
||||
### Audio playback issues
|
||||
|
||||
**Symptoms:** Generated audio won't play
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check audio format**
|
||||
|
||||
- Audio is saved as WAV files
|
||||
- Ensure your system supports WAV playback
|
||||
|
||||
2. **Try downloading audio**
|
||||
|
||||
- Right-click → Download
|
||||
- Play in external player
|
||||
|
||||
3. **Check browser permissions** (web version)
|
||||
- Allow audio autoplay in browser settings
|
||||
|
||||
### Slow generation
|
||||
|
||||
**Symptoms:** Generation takes >30 seconds
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check backend type** (Apple Silicon)
|
||||
|
||||
- Check Settings → Server Status
|
||||
- Should show "Backend: MLX" on Apple Silicon
|
||||
- If showing "Backend: PYTORCH", install MLX: `pip install -r backend/requirements-mlx.txt`
|
||||
- MLX provides 4-5x faster inference on Apple Silicon
|
||||
|
||||
2. **Use GPU** (if available)
|
||||
|
||||
- Check Settings → Server Status
|
||||
- Should show "GPU available: true"
|
||||
- Apple Silicon: Should show "Metal (Apple Silicon via MLX)"
|
||||
- Windows/Linux: Should show "CUDA" if GPU available
|
||||
|
||||
3. **Enable caching**
|
||||
|
||||
- Voice prompts are cached automatically
|
||||
- Second generation with same voice should be faster
|
||||
|
||||
4. **Use smaller model**
|
||||
|
||||
- 0.6B model is faster than 1.7B
|
||||
- Quality difference is minimal for most voices
|
||||
|
||||
5. **Check system resources**
|
||||
- Close other CPU/GPU intensive apps
|
||||
- Ensure adequate RAM (8GB+ recommended)
|
||||
|
||||
## API Issues
|
||||
|
||||
### "Connection refused" when using API
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check server is running**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
2. **Check remote mode**
|
||||
|
||||
- If connecting remotely, ensure server is started with `--host 0.0.0.0`
|
||||
- Check firewall settings
|
||||
|
||||
3. **Check port availability**
|
||||
- Default port is 8000
|
||||
- Ensure no other service is using it
|
||||
|
||||
### CORS errors in browser
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Use desktop app** (recommended)
|
||||
|
||||
- Desktop app doesn't have CORS restrictions
|
||||
|
||||
2. **Configure CORS** (for web deployment)
|
||||
- Update `backend/main.py` CORS settings
|
||||
- Add your domain to allowed origins
|
||||
|
||||
## Update Issues
|
||||
|
||||
### "Update check failed"
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check internet connection**
|
||||
|
||||
- Updates are fetched from GitHub releases
|
||||
|
||||
2. **Check GitHub access**
|
||||
|
||||
- Ensure `github.com` is accessible
|
||||
- Check firewall/proxy settings
|
||||
|
||||
3. **Manual update**
|
||||
- Download latest release from GitHub
|
||||
- Install manually
|
||||
|
||||
### "Invalid signature" error
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Re-download installer**
|
||||
|
||||
- Signature may be corrupted
|
||||
- Download fresh copy from GitHub
|
||||
|
||||
2. **Check release integrity**
|
||||
- Verify `.sig` file matches installer
|
||||
- Report issue if signature is invalid
|
||||
|
||||
## Data Issues
|
||||
|
||||
### Profiles disappeared
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check data directory**
|
||||
|
||||
- macOS: `~/Library/Application Support/voicebox/`
|
||||
- Windows: `%APPDATA%/voicebox/`
|
||||
- Linux: `~/.local/share/voicebox/`
|
||||
|
||||
2. **Check database**
|
||||
|
||||
- Database: `data/voicebox.db`
|
||||
- Ensure file exists and is readable
|
||||
|
||||
3. **Restore from backup**
|
||||
- Profiles can be exported/imported
|
||||
- Check for backup files
|
||||
|
||||
### "Database locked" error
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Close other instances**
|
||||
|
||||
- Ensure only one Voicebox instance is running
|
||||
|
||||
2. **Restart app**
|
||||
|
||||
- Close and reopen Voicebox
|
||||
|
||||
3. **Check file permissions**
|
||||
- Ensure database file is writable
|
||||
- Check directory permissions
|
||||
|
||||
## Development Issues
|
||||
|
||||
### Build fails
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check Rust installation**
|
||||
|
||||
```bash
|
||||
rustc --version
|
||||
rustup update
|
||||
```
|
||||
|
||||
2. **Check Tauri dependencies**
|
||||
|
||||
```bash
|
||||
cd tauri
|
||||
bun install
|
||||
```
|
||||
|
||||
3. **Clean build**
|
||||
```bash
|
||||
cd tauri/src-tauri
|
||||
cargo clean
|
||||
cd ../..
|
||||
bun run build
|
||||
```
|
||||
|
||||
### API client generation fails
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Start backend server**
|
||||
|
||||
```bash
|
||||
bun run dev:server
|
||||
```
|
||||
|
||||
2. **Check OpenAPI endpoint**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/openapi.json
|
||||
```
|
||||
|
||||
3. **Regenerate client**
|
||||
```bash
|
||||
bun run generate:api
|
||||
```
|
||||
|
||||
## Still Having Issues?
|
||||
|
||||
1. **Check existing issues**
|
||||
|
||||
- Search GitHub issues for similar problems
|
||||
- Check closed issues for solutions
|
||||
|
||||
2. **Create new issue**
|
||||
|
||||
- Include:
|
||||
- OS and version
|
||||
- Voicebox version
|
||||
- Steps to reproduce
|
||||
- Error messages/logs
|
||||
- Screenshots (if applicable)
|
||||
|
||||
3. **Get help**
|
||||
- Check documentation in `docs/`
|
||||
- Review `backend/README.md` for API details
|
||||
- See `CONTRIBUTING.md` for development help
|
||||
|
||||
---
|
||||
|
||||
For more help, open an issue on [GitHub](https://github.com/jamiepine/voicebox/issues).
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "API Reference",
|
||||
"pages": ["overview", "authentication", "voice-profiles", "generation", "recordings"]
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
---
|
||||
title: "Contributing"
|
||||
description: "How to contribute to Voicebox"
|
||||
---
|
||||
|
||||
Thank you for your interest in contributing to Voicebox! This guide will help you get started.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
- Be respectful and inclusive
|
||||
- Welcome newcomers and help them learn
|
||||
- Focus on constructive feedback
|
||||
- Respect different viewpoints and experiences
|
||||
|
||||
## Getting Started
|
||||
|
||||
Before you start contributing, make sure you have:
|
||||
|
||||
1. **Read the documentation** to understand how Voicebox works
|
||||
2. **Set up your development environment** - see [Development Setup](/development/setup)
|
||||
3. **Explored the codebase** to understand the project structure
|
||||
4. **Checked existing issues** to see if someone else is working on something similar
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Report Bugs" icon="bug">
|
||||
Found a bug? Open an issue with reproduction steps
|
||||
</Card>
|
||||
<Card title="Request Features" icon="lightbulb">
|
||||
Have an idea? Start a discussion or open an issue
|
||||
</Card>
|
||||
<Card title="Improve Docs" icon="book">
|
||||
Fix typos, add examples, or clarify instructions
|
||||
</Card>
|
||||
<Card title="Write Code" icon="code">
|
||||
Fix bugs, add features, or optimize performance
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. Fork & Clone
|
||||
|
||||
```bash
|
||||
# Fork the repository on GitHub
|
||||
# Then clone your fork
|
||||
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
||||
cd voicebox
|
||||
```
|
||||
|
||||
### 2. Create a Branch
|
||||
|
||||
Use descriptive branch names:
|
||||
|
||||
```bash
|
||||
# For features
|
||||
git checkout -b feature/voice-effects
|
||||
|
||||
# For bug fixes
|
||||
git checkout -b fix/audio-playback-issue
|
||||
|
||||
# For documentation
|
||||
git checkout -b docs/api-examples
|
||||
```
|
||||
|
||||
### 3. Make Your Changes
|
||||
|
||||
Follow these guidelines:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Code Style">
|
||||
**TypeScript/React:**
|
||||
- Use TypeScript strict mode
|
||||
- Prefer functional components with hooks
|
||||
- Use named exports
|
||||
- Format with Biome (runs automatically)
|
||||
|
||||
**Python:**
|
||||
- Follow PEP 8
|
||||
- Use type hints
|
||||
- Use async/await for I/O
|
||||
- Document functions with docstrings
|
||||
|
||||
**Rust:**
|
||||
- Follow Rust conventions
|
||||
- Use meaningful names
|
||||
- Handle errors explicitly
|
||||
- Run `rustfmt`
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Commit Messages">
|
||||
Write clear, descriptive commit messages:
|
||||
|
||||
```bash
|
||||
# Good
|
||||
git commit -m "Add voice profile export feature"
|
||||
git commit -m "Fix audio playback stopping after 30 seconds"
|
||||
|
||||
# Avoid
|
||||
git commit -m "Update code"
|
||||
git commit -m "Fix bug"
|
||||
```
|
||||
|
||||
Format:
|
||||
- Use imperative mood ("Add feature" not "Added feature")
|
||||
- Keep first line under 50 characters
|
||||
- Add detailed description if needed
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Testing">
|
||||
- Test your changes manually in the app
|
||||
- Ensure backend API endpoints work
|
||||
- Check for TypeScript/Python errors
|
||||
- Verify UI components render correctly
|
||||
- Add automated tests when possible
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### 4. Push & Create PR
|
||||
|
||||
```bash
|
||||
# Push your branch
|
||||
git push origin feature/your-feature-name
|
||||
|
||||
# Then create a pull request on GitHub
|
||||
```
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
When creating a pull request:
|
||||
|
||||
<Steps>
|
||||
<Step title="Use a Clear Title">
|
||||
Examples:
|
||||
- "Add voice profile export functionality"
|
||||
- "Fix audio playback stopping after 30 seconds"
|
||||
- "Improve generation speed with caching"
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Provide Description">
|
||||
Include: - What changes you made - Why you made them - How to test them -
|
||||
Screenshots (for UI changes) - Reference related issues
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Update Documentation">
|
||||
- Update relevant docs if behavior changes - Add API documentation for new
|
||||
endpoints - Update README if needed
|
||||
</Step>
|
||||
|
||||
<Step title="Check the Checklist">
|
||||
- [ ] Code follows style guidelines
|
||||
- [ ] Documentation updated
|
||||
- [ ] Changes tested
|
||||
- [ ] No breaking changes (or documented)
|
||||
- [ ] CHANGELOG.md updated
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Project Structure
|
||||
|
||||
Understanding the codebase:
|
||||
|
||||
```
|
||||
voicebox/
|
||||
├── app/ # Shared React frontend
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI components
|
||||
│ │ ├── lib/ # Utilities and API client
|
||||
│ │ ├── hooks/ # React hooks
|
||||
│ │ └── stores/ # Zustand state stores
|
||||
├── backend/ # Python FastAPI server
|
||||
│ ├── main.py # API routes
|
||||
│ ├── tts.py # Voice synthesis logic
|
||||
│ ├── database.py # SQLite operations
|
||||
│ └── models.py # Pydantic models
|
||||
├── tauri/ # Desktop app wrapper
|
||||
│ └── src-tauri/ # Rust backend
|
||||
├── web/ # Web deployment
|
||||
├── landing/ # Marketing website
|
||||
└── scripts/ # Build & release scripts
|
||||
```
|
||||
|
||||
## Areas for Contribution
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Check [existing issues](https://github.com/jamiepine/voicebox/issues) for bugs
|
||||
- Test your fix thoroughly
|
||||
- Add regression tests if possible
|
||||
|
||||
### New Features
|
||||
|
||||
- Check the [roadmap](https://github.com/jamiepine/voicebox#roadmap) for planned features
|
||||
- Discuss major features in an issue first
|
||||
- Keep features focused and well-scoped
|
||||
|
||||
### Documentation
|
||||
|
||||
- Improve clarity and fix typos
|
||||
- Add code examples
|
||||
- Create tutorials or guides
|
||||
- Document API endpoints
|
||||
|
||||
### UI/UX Improvements
|
||||
|
||||
- Improve accessibility
|
||||
- Enhance visual design
|
||||
- Optimize performance
|
||||
- Add animations/transitions
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- Improve build process
|
||||
- Add CI/CD improvements
|
||||
- Optimize bundle size
|
||||
- Add testing infrastructure
|
||||
|
||||
## API Development
|
||||
|
||||
When adding new API endpoints:
|
||||
|
||||
<Steps>
|
||||
<Step title="Add Route">
|
||||
In `backend/main.py`:
|
||||
|
||||
```python
|
||||
@app.post("/api/new-endpoint")
|
||||
async def new_endpoint(data: RequestModel) -> ResponseModel:
|
||||
"""Endpoint description."""
|
||||
# Implementation
|
||||
return response
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Create Models">
|
||||
In `backend/models.py`:
|
||||
|
||||
```python
|
||||
class RequestModel(BaseModel):
|
||||
field: str
|
||||
|
||||
class ResponseModel(BaseModel):
|
||||
result: str
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Regenerate Client">
|
||||
```bash
|
||||
bun run generate:api
|
||||
```
|
||||
|
||||
This updates the TypeScript client with type-safe bindings.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Update Docs">
|
||||
The API documentation is automatically generated from the OpenAPI schema. Ensure your endpoint has proper docstrings and type hints, then regenerate the docs:
|
||||
|
||||
```bash
|
||||
bun run generate:api
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Testing
|
||||
|
||||
Currently testing is primarily manual. When adding tests:
|
||||
|
||||
**Backend:**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pytest
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
|
||||
```bash
|
||||
bun run test
|
||||
```
|
||||
|
||||
**E2E (future):**
|
||||
|
||||
```bash
|
||||
bun run test:e2e
|
||||
```
|
||||
|
||||
## Release Process
|
||||
|
||||
Releases are managed by maintainers using `bumpversion`:
|
||||
|
||||
```bash
|
||||
# Bump version (patch, minor, or major)
|
||||
bumpversion patch
|
||||
|
||||
# Push with tags
|
||||
git push && git push --tags
|
||||
```
|
||||
|
||||
GitHub Actions automatically builds and publishes releases when tags are pushed.
|
||||
|
||||
## Community
|
||||
|
||||
- **GitHub Issues:** Bug reports and feature requests
|
||||
- **GitHub Discussions:** General questions and ideas
|
||||
- **Discord:** Real-time chat (coming soon)
|
||||
|
||||
## Recognition
|
||||
|
||||
Contributors are recognized in:
|
||||
|
||||
- [CHANGELOG.md](https://github.com/jamiepine/voicebox/blob/main/CHANGELOG.md)
|
||||
- GitHub contributor list
|
||||
- Release notes
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions:
|
||||
|
||||
1. Check the [documentation](/overview/introduction)
|
||||
2. Search [existing issues](https://github.com/jamiepine/voicebox/issues)
|
||||
3. Open a new issue or discussion
|
||||
4. See [CONTRIBUTING.md](https://github.com/jamiepine/voicebox/blob/main/CONTRIBUTING.md) in the repo
|
||||
|
||||
Thank you for contributing to Voicebox! 🎉
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Developer",
|
||||
"pages": [
|
||||
"setup",
|
||||
"architecture",
|
||||
"contributing",
|
||||
"building",
|
||||
"autoupdater",
|
||||
"voice-profiles",
|
||||
"tts-generation",
|
||||
"history",
|
||||
"stories",
|
||||
"transcription",
|
||||
"audio-channels",
|
||||
"model-management"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
title: "Development Setup"
|
||||
description: "Set up your local development environment for Voicebox"
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following installed:
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Bun" icon="package">
|
||||
[Download Bun](https://bun.sh) ```bash curl -fsSL https://bun.sh/install |
|
||||
bash ```
|
||||
</Card>
|
||||
<Card title="Python 3.11+" icon="python">
|
||||
[Download Python](https://python.org) ```bash python --version ```
|
||||
</Card>
|
||||
<Card title="Rust" icon="rust">
|
||||
[Install Rust](https://rustup.rs) ```bash rustc --version ```
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jamiepine/voicebox.git
|
||||
cd voicebox
|
||||
```
|
||||
|
||||
## Quick Setup (Recommended)
|
||||
|
||||
The easiest way to get started is using the Makefile:
|
||||
|
||||
```bash
|
||||
# Setup everything
|
||||
make setup
|
||||
|
||||
# Start development
|
||||
make dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
The Makefile is available on macOS and Linux. Windows users should follow the
|
||||
manual setup below.
|
||||
</Note>
|
||||
|
||||
## Manual Setup
|
||||
|
||||
### 1. Install JavaScript Dependencies
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
This installs dependencies for:
|
||||
|
||||
- `app/` - Shared React frontend
|
||||
- `tauri/` - Tauri desktop wrapper
|
||||
- `web/` - Web deployment wrapper
|
||||
|
||||
### 2. Set Up Python Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate # macOS/Linux
|
||||
# or
|
||||
venv\Scripts\activate # Windows
|
||||
|
||||
# Install Python dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Install MLX dependencies (Apple Silicon only - for faster inference)
|
||||
# On Apple Silicon, this enables native Metal acceleration
|
||||
if [[ $(uname -m) == "arm64" ]]; then
|
||||
pip install -r requirements-mlx.txt
|
||||
fi
|
||||
|
||||
# Install Qwen3-TTS
|
||||
pip install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
```
|
||||
|
||||
## Running in Development
|
||||
|
||||
Development requires **two terminals**: one for the Python backend, one for the Tauri app.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Terminal 1: Backend">
|
||||
Start the Python server first:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate # Activate venv
|
||||
bun run dev:server
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
uvicorn main:app --reload --port 17493
|
||||
```
|
||||
|
||||
Backend will be available at `http://localhost:17493`
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Terminal 2: Desktop App">
|
||||
Then start the Tauri app:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
This will:
|
||||
- Create a placeholder sidecar binary
|
||||
- Start Vite dev server on port 5173
|
||||
- Launch Tauri window
|
||||
- Enable hot reload
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Info>
|
||||
In dev mode, the app connects to your manually-started Python server. The
|
||||
bundled server binary is only used in production builds.
|
||||
</Info>
|
||||
|
||||
### Optional: Web App
|
||||
|
||||
```bash
|
||||
bun run dev:web
|
||||
```
|
||||
|
||||
Web app will be available at `http://localhost:5174`
|
||||
|
||||
## Model Downloads
|
||||
|
||||
Models are automatically downloaded from HuggingFace Hub on first use:
|
||||
|
||||
- **Whisper** (transcription): Auto-downloads on first transcription
|
||||
- **Qwen3-TTS** (voice cloning): Auto-downloads on first generation (~2-4GB)
|
||||
|
||||
<Warning>
|
||||
First-time usage will be slower due to model downloads, but subsequent runs
|
||||
will use cached models.
|
||||
</Warning>
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
voicebox/
|
||||
├── app/ # Shared React frontend
|
||||
│ └── src/
|
||||
│ ├── components/ # UI components
|
||||
│ ├── lib/ # Utilities and API client
|
||||
│ └── hooks/ # React hooks
|
||||
├── backend/ # Python FastAPI server
|
||||
│ ├── main.py # API routes
|
||||
│ ├── tts.py # Voice synthesis
|
||||
│ └── database.py # SQLite operations
|
||||
├── tauri/ # Desktop app wrapper
|
||||
│ └── src-tauri/ # Rust backend
|
||||
├── web/ # Web deployment
|
||||
├── landing/ # Marketing website
|
||||
└── scripts/ # Build & release scripts
|
||||
```
|
||||
|
||||
## Available Make Commands
|
||||
|
||||
Run `make help` to see all available commands:
|
||||
|
||||
```bash
|
||||
make setup # Install all dependencies
|
||||
make dev # Start development servers
|
||||
make dev-web # Start web development server
|
||||
make build # Build desktop app
|
||||
make build-web # Build web app
|
||||
make clean # Clean build artifacts
|
||||
make test # Run tests
|
||||
```
|
||||
|
||||
## Generate OpenAPI Client
|
||||
|
||||
After starting the backend server, generate the TypeScript API client:
|
||||
|
||||
```bash
|
||||
./scripts/generate-api.sh
|
||||
# or
|
||||
bun run generate:api
|
||||
```
|
||||
|
||||
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card
|
||||
title="Architecture"
|
||||
icon="diagram-project"
|
||||
href="/development/architecture"
|
||||
>
|
||||
Understand the system architecture
|
||||
</Card>
|
||||
<Card
|
||||
title="Contributing"
|
||||
icon="code-pull-request"
|
||||
href="/development/contributing"
|
||||
>
|
||||
Read the contribution guidelines
|
||||
</Card>
|
||||
<Card title="Building" icon="hammer" href="/development/building">
|
||||
Learn how to build production releases
|
||||
</Card>
|
||||
<Card title="API Reference" icon="code" href="/api-reference">
|
||||
Explore the REST API
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Backend won't start">
|
||||
- Check Python version (must be 3.11+)
|
||||
- Ensure virtual environment is activated
|
||||
- Verify all dependencies are installed: `pip install -r requirements.txt`
|
||||
- Check if port 17493 is available
|
||||
</Accordion>
|
||||
|
||||
{" "}
|
||||
<Accordion title="Tauri build fails">
|
||||
- Ensure Rust is installed: `rustc --version` - Clean the build: `cd
|
||||
tauri/src-tauri && cargo clean` - Try rebuilding: `bun run dev`
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="OpenAPI client generation fails">
|
||||
- Ensure backend is running: `curl http://localhost:17493/openapi.json`
|
||||
- Check network connectivity
|
||||
- Verify the backend is accessible at localhost:17493
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
See the full [Troubleshooting Guide](/guides/troubleshooting) for more issues and solutions.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: "Voicebox Documentation"
|
||||
description: "Welcome to Voicebox - the open-source voice synthesis studio"
|
||||
---
|
||||
|
||||
## What is Voicebox?
|
||||
|
||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as the **Ollama for voice** — download models, clone voices, and generate speech entirely on your machine.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/app-screenshot-1.webp" alt="Voicebox App Screenshot" />
|
||||
</Frame>
|
||||
|
||||
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives you:
|
||||
|
||||
- **Complete privacy** — models and voice data stay on your machine
|
||||
- **Professional tools** — multi-track timeline editor, audio trimming, conversation mixing
|
||||
- **Model flexibility** — currently powered by Qwen3-TTS, with support for XTTS, Bark, and other models coming soon
|
||||
- **API-first** — use the desktop app or integrate voice synthesis into your own projects
|
||||
- **Native performance** — built with Tauri (Rust), not Electron
|
||||
|
||||
Download a voice model, clone any voice from a few seconds of audio, and compose multi-voice projects with studio-grade editing tools. No Python install required, no cloud dependency, no limits.
|
||||
|
||||
## Key Features
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Voice Cloning" icon="microphone">
|
||||
Instant cloning from just a few seconds of audio with Qwen3-TTS
|
||||
</Card>
|
||||
<Card title="Stories Editor" icon="film">
|
||||
Multi-track timeline for creating conversations and narratives
|
||||
</Card>
|
||||
<Card title="Full API" icon="code">
|
||||
REST API for integrating voice synthesis into your apps
|
||||
</Card>
|
||||
<Card title="Local-First" icon="shield">
|
||||
Everything runs on your machine - complete privacy
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Get Started
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Installation" icon="download" href="/docs/overview/installation">
|
||||
Download and install Voicebox on your machine
|
||||
</Card>
|
||||
<Card title="Quick Start" icon="rocket" href="/docs/overview/quick-start">
|
||||
Get up and running in 5 minutes
|
||||
</Card>
|
||||
</CardGroup>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user