mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-02 00:25:15 -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]
|
[bumpversion]
|
||||||
current_version = 0.1.11
|
current_version = 0.1.13
|
||||||
commit = True
|
commit = True
|
||||||
tag = True
|
tag = True
|
||||||
tag_name = v{new_version}
|
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:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
|
|
||||||
|
env:
|
||||||
|
PROVIDER_VERSION: "1.0.0"
|
||||||
|
|
||||||
jobs:
|
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:
|
release:
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -14,22 +158,26 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- platform: 'macos-latest'
|
# macOS Apple Silicon - MLX bundled (works out of the box)
|
||||||
args: '--target aarch64-apple-darwin'
|
- platform: "macos-latest"
|
||||||
python-version: '3.12'
|
args: "--target aarch64-apple-darwin"
|
||||||
backend: 'mlx'
|
python-version: "3.12"
|
||||||
- platform: 'macos-15-intel'
|
backend: "mlx"
|
||||||
args: '--target x86_64-apple-darwin'
|
# macOS Intel - PyTorch bundled (smaller user base, keep simple)
|
||||||
python-version: '3.12'
|
- platform: "macos-15-intel"
|
||||||
backend: 'pytorch'
|
args: "--target x86_64-apple-darwin"
|
||||||
# - platform: 'ubuntu-22.04'
|
python-version: "3.12"
|
||||||
# args: ''
|
backend: "pytorch"
|
||||||
# python-version: '3.12'
|
# Linux - No TTS bundled, providers downloaded separately
|
||||||
# backend: 'pytorch'
|
- platform: "ubuntu-22.04"
|
||||||
- platform: 'windows-latest'
|
args: ""
|
||||||
args: ''
|
python-version: "3.12"
|
||||||
python-version: '3.12'
|
backend: "none"
|
||||||
backend: 'pytorch'
|
# Windows - PyTorch CPU bundled (works out of the box)
|
||||||
|
- platform: "windows-latest"
|
||||||
|
args: ""
|
||||||
|
python-version: "3.12"
|
||||||
|
backend: "pytorch"
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
@@ -40,7 +188,7 @@ jobs:
|
|||||||
if: matrix.platform == 'ubuntu-22.04'
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
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)
|
- name: Install LLVM (macOS)
|
||||||
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
|
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
|
||||||
@@ -53,14 +201,24 @@ jobs:
|
|||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
cache: 'pip'
|
cache: "pip"
|
||||||
|
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies (with TTS)
|
||||||
|
if: matrix.backend != 'none'
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install pyinstaller
|
pip install pyinstaller
|
||||||
pip install -r backend/requirements.txt
|
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)
|
- name: Install MLX dependencies (Apple Silicon only)
|
||||||
if: matrix.backend == 'mlx'
|
if: matrix.backend == 'mlx'
|
||||||
run: |
|
run: |
|
||||||
@@ -100,7 +258,7 @@ jobs:
|
|||||||
- name: Rust cache
|
- name: Rust cache
|
||||||
uses: swatinem/rust-cache@v2
|
uses: swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
workspaces: './tauri/src-tauri -> target'
|
workspaces: "./tauri/src-tauri -> target"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
@@ -136,19 +294,90 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
projectPath: tauri
|
projectPath: tauri
|
||||||
tagName: v__VERSION__
|
tagName: v__VERSION__
|
||||||
releaseName: 'voicebox v__VERSION__'
|
releaseName: "voicebox v__VERSION__"
|
||||||
releaseBody: |
|
releaseBody: |
|
||||||
## What's Changed
|
## What's Changed
|
||||||
See the assets below to download and install this version.
|
See the assets below to download and install this version.
|
||||||
|
|
||||||
### Installation
|
### 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
|
- **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch
|
||||||
- **Windows**: Download the `.msi` installer
|
- **Windows**: Download the `.msi` installer - requires downloading a TTS provider on first use
|
||||||
- **Linux**: Download the `.AppImage` or `.deb` package
|
- **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.
|
The app includes automatic updates - future updates will be installed automatically.
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
includeUpdaterJson: true
|
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/
|
build/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
*.egg
|
*.egg
|
||||||
|
*.spec
|
||||||
target/
|
target/
|
||||||
*.app
|
*.app
|
||||||
*.dmg
|
*.dmg
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Audio export failing when Tauri save dialog returns object instead of string path
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
|
- **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
|
||||||
- Includes Python version detection and compatibility warnings
|
- Includes Python version detection and compatibility warnings
|
||||||
|
|||||||
+141
@@ -14,16 +14,19 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- **[Bun](https://bun.sh)** - Fast JavaScript runtime and package manager
|
- **[Bun](https://bun.sh)** - Fast JavaScript runtime and package manager
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://bun.sh/install | bash
|
curl -fsSL https://bun.sh/install | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
- **[Python 3.11+](https://python.org)** - For backend development
|
- **[Python 3.11+](https://python.org)** - For backend development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python --version # Should be 3.11 or higher
|
python --version # Should be 3.11 or higher
|
||||||
```
|
```
|
||||||
|
|
||||||
- **[Rust](https://rustup.rs)** - For Tauri desktop app (installed automatically by Tauri CLI)
|
- **[Rust](https://rustup.rs)** - For Tauri desktop app (installed automatically by Tauri CLI)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rustc --version # Check if installed
|
rustc --version # Check if installed
|
||||||
```
|
```
|
||||||
@@ -37,21 +40,26 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
|||||||
**Manual setup (required for Windows):**
|
**Manual setup (required for Windows):**
|
||||||
|
|
||||||
1. **Fork and clone the repository**
|
1. **Fork and clone the repository**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
||||||
cd voicebox
|
cd voicebox
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Install JavaScript dependencies**
|
2. **Install JavaScript dependencies**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs dependencies for:
|
This installs dependencies for:
|
||||||
|
|
||||||
- `app/` - Shared React frontend
|
- `app/` - Shared React frontend
|
||||||
- `tauri/` - Tauri desktop wrapper
|
- `tauri/` - Tauri desktop wrapper
|
||||||
- `web/` - Web deployment wrapper
|
- `web/` - Web deployment wrapper
|
||||||
|
|
||||||
3. **Set up Python backend**
|
3. **Set up Python backend**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
|
|
||||||
@@ -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.
|
Development requires two terminals: one for the Python backend, one for the Tauri app.
|
||||||
|
|
||||||
**Terminal 1: Backend server** (start this first)
|
**Terminal 1: Backend server** (start this first)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
source venv/bin/activate # Activate venv if not already active
|
source venv/bin/activate # Activate venv if not already active
|
||||||
bun run dev:server
|
bun run dev:server
|
||||||
# Or manually: uvicorn main:app --reload --port 17493
|
# Or manually: uvicorn main:app --reload --port 17493
|
||||||
```
|
```
|
||||||
|
|
||||||
Backend will be available at `http://localhost:17493`
|
Backend will be available at `http://localhost:17493`
|
||||||
|
|
||||||
**Terminal 2: Desktop app**
|
**Terminal 2: Desktop app**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
This will:
|
This will:
|
||||||
|
|
||||||
- Create a placeholder sidecar binary (for Tauri compilation)
|
- Create a placeholder sidecar binary (for Tauri compilation)
|
||||||
- Start Vite dev server on port 5173
|
- Start Vite dev server on port 5173
|
||||||
- Launch Tauri window pointing to localhost: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.
|
> The bundled server binary is only used in production builds.
|
||||||
|
|
||||||
**Optional: Web app**
|
**Optional: Web app**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev:web
|
bun run dev:web
|
||||||
```
|
```
|
||||||
|
|
||||||
Web app will be available at `http://localhost:5174`
|
Web app will be available at `http://localhost:5174`
|
||||||
|
|
||||||
### Model Downloads
|
### Model Downloads
|
||||||
|
|
||||||
Models are automatically downloaded from HuggingFace Hub on first use:
|
Models are automatically downloaded from HuggingFace Hub on first use:
|
||||||
|
|
||||||
- **Whisper** (transcription): Auto-downloads on first transcription
|
- **Whisper** (transcription): Auto-downloads on first transcription
|
||||||
- **Qwen3-TTS** (voice cloning): Auto-downloads on first generation (~2-4GB)
|
- **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.
|
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
|
### Building
|
||||||
|
|
||||||
**Build everything (recommended):**
|
**Build everything (recommended):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run build
|
bun run build
|
||||||
```
|
```
|
||||||
|
|
||||||
This automatically:
|
This automatically:
|
||||||
|
|
||||||
1. Builds the Python server binary (`./scripts/build-server.sh`)
|
1. Builds the Python server binary (`./scripts/build-server.sh`)
|
||||||
2. Builds the Tauri desktop app (`cd tauri && bun run tauri build`)
|
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).
|
**Note:** The build process detects your platform and includes the appropriate backend (MLX for Apple Silicon, PyTorch for others).
|
||||||
|
|
||||||
**Build server binary only:**
|
**Build server binary only:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run build:server
|
bun run build:server
|
||||||
# or
|
# or
|
||||||
./scripts/build-server.sh
|
./scripts/build-server.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Creates platform-specific binary in `tauri/src-tauri/binaries/`
|
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:**
|
**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:
|
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`).
|
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:**
|
**Build web app:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web
|
cd web
|
||||||
bun run build
|
bun run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Output in `web/dist/`
|
Output in `web/dist/`
|
||||||
|
|
||||||
### Generate OpenAPI Client
|
### Generate OpenAPI Client
|
||||||
|
|
||||||
After starting the backend server:
|
After starting the backend server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/generate-api.sh
|
./scripts/generate-api.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`
|
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`
|
||||||
|
|
||||||
### Convert Assets to Web Formats
|
### Convert Assets to Web Formats
|
||||||
|
|
||||||
To optimize images and videos for the web, run:
|
To optimize images and videos for the web, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run convert:assets
|
bun run convert:assets
|
||||||
```
|
```
|
||||||
|
|
||||||
This script:
|
This script:
|
||||||
|
|
||||||
- Converts PNG → WebP (better compression, same quality)
|
- Converts PNG → WebP (better compression, same quality)
|
||||||
- Converts MOV → WebM (VP9 codec, smaller file size)
|
- Converts MOV → WebM (VP9 codec, smaller file size)
|
||||||
- Processes files in `landing/public/` and `docs/public/`
|
- Processes files in `landing/public/` and `docs/public/`
|
||||||
- **Deletes original files** after successful conversion
|
- **Deletes original files** after successful conversion
|
||||||
|
|
||||||
**Requirements:** Install `webp` and `ffmpeg`:
|
**Requirements:** Install `webp` and `ffmpeg`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install webp ffmpeg
|
brew install webp ffmpeg
|
||||||
```
|
```
|
||||||
@@ -225,6 +362,7 @@ git push origin feature/your-feature-name
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then create a pull request on GitHub with:
|
Then create a pull request on GitHub with:
|
||||||
|
|
||||||
- Clear description of changes
|
- Clear description of changes
|
||||||
- Screenshots (for UI changes)
|
- Screenshots (for UI changes)
|
||||||
- Reference to related issues
|
- Reference to related issues
|
||||||
@@ -370,6 +508,7 @@ Currently, testing is primarily manual. When adding tests:
|
|||||||
Releases are managed by maintainers:
|
Releases are managed by maintainers:
|
||||||
|
|
||||||
1. **Bump version using bumpversion:**
|
1. **Bump version using bumpversion:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install bumpversion (if not already installed)
|
# Install bumpversion (if not already installed)
|
||||||
pip install bumpversion
|
pip install bumpversion
|
||||||
@@ -385,6 +524,7 @@ Releases are managed by maintainers:
|
|||||||
```
|
```
|
||||||
|
|
||||||
This automatically:
|
This automatically:
|
||||||
|
|
||||||
- Updates version numbers in all files (`tauri.conf.json`, `Cargo.toml`, all `package.json` files, `backend/main.py`)
|
- 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 commit with the version bump
|
||||||
- Creates a git tag (e.g., `v0.1.1`, `v0.2.0`)
|
- 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
|
2. **Update CHANGELOG.md** with release notes
|
||||||
|
|
||||||
3. **Push commits and tags:**
|
3. **Push commits and tags:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git push
|
git push
|
||||||
git push --tags
|
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?
|
## 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:
|
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
|
## Download
|
||||||
|
|
||||||
Voicebox is available now for macOS and Windows.
|
### Desktop App
|
||||||
|
|
||||||
| Platform | Download |
|
| 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 (Apple Silicon) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||||
| macOS (Intel) | [voicebox_x64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_x64.app.tar.gz) |
|
| macOS (Intel) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||||
| 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 (MSI) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||||
| 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) |
|
| 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
|
### Flexible Deployment
|
||||||
|
|
||||||
- **Local mode** — Everything runs on your machine
|
- **Desktop app** — Native apps for macOS, Windows, and Linux
|
||||||
- **Remote mode** — Connect to a GPU server on your network
|
- **Docker** — Deploy to servers with the web UI included
|
||||||
- **One-click server** — Turn any machine into a Voicebox server
|
- **Remote mode** — Connect desktop app to a remote GPU server
|
||||||
|
- **Cloud ready** — Deploy to AWS, GCP, DigitalOcean, or any cloud provider
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -177,7 +201,7 @@ Full API documentation available at `http://localhost:8000/docs` when running.
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|-------|------------|
|
| ---------------- | --------------------------------------------------- |
|
||||||
| Desktop App | Tauri (Rust) |
|
| Desktop App | Tauri (Rust) |
|
||||||
| Frontend | React, TypeScript, Tailwind CSS |
|
| Frontend | React, TypeScript, Tailwind CSS |
|
||||||
| State | Zustand, React Query |
|
| State | Zustand, React Query |
|
||||||
@@ -194,6 +218,26 @@ Full API documentation available at `http://localhost:8000/docs` when running.
|
|||||||
- **FastAPI** — Async Python with automatic OpenAPI schema generation
|
- **FastAPI** — Async Python with automatic OpenAPI schema generation
|
||||||
- **Type-safe end-to-end** — Generated TypeScript client from OpenAPI spec
|
- **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
|
## Roadmap
|
||||||
@@ -203,7 +247,7 @@ Voicebox is the beginning of something bigger. Here's what's coming:
|
|||||||
### Coming Soon
|
### Coming Soon
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
| ----------------------- | -------------------------------------------------- |
|
||||||
| **Real-time Synthesis** | Stream audio as it generates, word by word |
|
| **Real-time Synthesis** | Stream audio as it generates, word by word |
|
||||||
| **Conversation Mode** | Multi-speaker dialogues with automatic turn-taking |
|
| **Conversation Mode** | Multi-speaker dialogues with automatic turn-taking |
|
||||||
| **Voice Effects** | Pitch shift, reverb, M3GAN-style effects |
|
| **Voice Effects** | Pitch shift, reverb, M3GAN-style effects |
|
||||||
@@ -263,6 +307,7 @@ 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
|
- **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)
|
- **Windows/Linux/Intel Mac**: Uses PyTorch backend (CUDA GPU recommended, CPU supported but slower)
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.11",
|
"version": "0.1.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,6 +17,10 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@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-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
@@ -24,6 +28,7 @@
|
|||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-popover": "^1.1.1",
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
"@radix-ui/react-progress": "^1.1.0",
|
"@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-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.1",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
@@ -43,7 +48,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"framer-motion": "^12.29.0",
|
"framer-motion": "^12.29.0",
|
||||||
"lucide-react": "^0.454.0",
|
|
||||||
"motion": "^12.29.0",
|
"motion": "^12.29.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^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 { RouterProvider } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
import ShinyText from '@/components/ShinyText';
|
import ShinyText from '@/components/ShinyText';
|
||||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||||
|
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { router } from '@/router';
|
import { router } from '@/router';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
|
||||||
|
|
||||||
const LOADING_MESSAGES = [
|
const LOADING_MESSAGES = [
|
||||||
'Warming up tensors...',
|
'Warming up tensors...',
|
||||||
@@ -38,6 +39,9 @@ function App() {
|
|||||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||||
const serverStartingRef = useRef(false);
|
const serverStartingRef = useRef(false);
|
||||||
|
|
||||||
|
// Automatically check for app updates on startup and show toast notifications
|
||||||
|
useAutoUpdater(true);
|
||||||
|
|
||||||
// Sync stored setting to Rust on startup
|
// Sync stored setting to Rust on startup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (platform.metadata.isTauri) {
|
if (platform.metadata.isTauri) {
|
||||||
@@ -46,14 +50,18 @@ function App() {
|
|||||||
console.error('Failed to sync initial setting to Rust:', error);
|
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
|
// Setup lifecycle callbacks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
platform.lifecycle.onServerReady = () => {
|
platform.lifecycle.onServerReady = () => {
|
||||||
setServerReady(true);
|
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)
|
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,8 +82,7 @@ function App() {
|
|||||||
console.log('Dev mode: Skipping auto-start of server (run it separately)');
|
console.log('Dev mode: Skipping auto-start of server (run it separately)');
|
||||||
setServerReady(true); // Mark as ready so UI doesn't show loading screen
|
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)
|
// 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 as any).__voiceboxServerStartedByApp = false;
|
||||||
window.__voiceboxServerStartedByApp = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,14 +102,12 @@ function App() {
|
|||||||
useServerStore.getState().setServerUrl(serverUrl);
|
useServerStore.getState().setServerUrl(serverUrl);
|
||||||
setServerReady(true);
|
setServerReady(true);
|
||||||
// Mark that we started the server (so we know to stop it on close)
|
// Mark that we started the server (so we know to stop it on close)
|
||||||
// @ts-expect-error - adding property to window
|
(window as any).__voiceboxServerStartedByApp = true;
|
||||||
window.__voiceboxServerStartedByApp = true;
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to auto-start server:', error);
|
console.error('Failed to auto-start server:', error);
|
||||||
serverStartingRef.current = false;
|
serverStartingRef.current = false;
|
||||||
// @ts-expect-error - adding property to window
|
(window as any).__voiceboxServerStartedByApp = false;
|
||||||
window.__voiceboxServerStartedByApp = false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup: stop server on actual unmount (not StrictMode remount)
|
// Cleanup: stop server on actual unmount (not StrictMode remount)
|
||||||
@@ -111,7 +116,9 @@ function App() {
|
|||||||
// Window close event handles server shutdown based on setting
|
// Window close event handles server shutdown based on setting
|
||||||
serverStartingRef.current = false;
|
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
|
// Cycle through loading messages every 3 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -459,7 +460,7 @@ export function AudioPlayer() {
|
|||||||
// Use double requestAnimationFrame to ensure DOM is fully rendered
|
// Use double requestAnimationFrame to ensure DOM is fully rendered
|
||||||
let rafId1: number;
|
let rafId1: number;
|
||||||
let rafId2: number;
|
let rafId2: number;
|
||||||
let timeoutId: number | null = null;
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
rafId1 = requestAnimationFrame(() => {
|
rafId1 = requestAnimationFrame(() => {
|
||||||
rafId2 = requestAnimationFrame(() => {
|
rafId2 = requestAnimationFrame(() => {
|
||||||
@@ -832,7 +833,7 @@ export function AudioPlayer() {
|
|||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
|
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>
|
</Button>
|
||||||
|
|
||||||
{/* Waveform */}
|
{/* Waveform */}
|
||||||
@@ -873,7 +874,7 @@ export function AudioPlayer() {
|
|||||||
className={isLooping ? 'text-primary' : ''}
|
className={isLooping ? 'text-primary' : ''}
|
||||||
title="Toggle loop"
|
title="Toggle loop"
|
||||||
>
|
>
|
||||||
<Repeat className="h-4 w-4" />
|
<HugeiconsIcon icon={RepeatIcon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Volume Control */}
|
{/* Volume Control */}
|
||||||
@@ -884,7 +885,7 @@ export function AudioPlayer() {
|
|||||||
onClick={() => setVolume(volume > 0 ? 0 : 1)}
|
onClick={() => setVolume(volume > 0 ? 0 : 1)}
|
||||||
className="h-8 w-8"
|
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>
|
</Button>
|
||||||
<Slider
|
<Slider
|
||||||
value={[volume * 100]}
|
value={[volume * 100]}
|
||||||
@@ -903,7 +904,7 @@ export function AudioPlayer() {
|
|||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
title="Close player"
|
title="Close player"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<HugeiconsIcon icon={Cancel01Icon} size={20} className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
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 { useState } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -135,7 +136,7 @@ export function AudioTab() {
|
|||||||
<div className="flex items-center justify-between mb-6 shrink-0">
|
<div className="flex items-center justify-between mb-6 shrink-0">
|
||||||
<h2 className="text-2xl font-bold">Audio Channels</h2>
|
<h2 className="text-2xl font-bold">Audio Channels</h2>
|
||||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
<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
|
New Channel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,13 +151,13 @@ export function AudioTab() {
|
|||||||
>
|
>
|
||||||
{allChannels.length === 0 ? (
|
{allChannels.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
<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">
|
<p className="text-muted-foreground mb-4">
|
||||||
No audio channels yet. Create your first channel to route voices to specific
|
No audio channels yet. Create your first channel to route voices to specific
|
||||||
devices.
|
devices.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
<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
|
Create Channel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +179,7 @@ export function AudioTab() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<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">
|
<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>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
|
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
|
||||||
@@ -235,7 +236,7 @@ export function AudioTab() {
|
|||||||
setEditingChannel(channel.id);
|
setEditingChannel(channel.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<HugeiconsIcon icon={Edit01Icon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -325,10 +326,10 @@ export function AudioTab() {
|
|||||||
isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30',
|
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>
|
</div>
|
||||||
) : device.is_default ? (
|
) : 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}
|
) : null}
|
||||||
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
|
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
|
||||||
{device.name}
|
{device.name}
|
||||||
@@ -339,7 +340,7 @@ export function AudioTab() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
<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">
|
<p className="text-muted-foreground text-center">
|
||||||
{platform.metadata.isTauri ? 'No audio devices found' : 'Audio device selection requires Tauri'}
|
{platform.metadata.isTauri ? 'No audio devices found' : 'Audio device selection requires Tauri'}
|
||||||
</p>
|
</p>
|
||||||
@@ -494,7 +495,7 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
|
|||||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<HugeiconsIcon icon={Delete01Icon} size={12} className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -602,7 +603,7 @@ function EditChannelDialog({
|
|||||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<HugeiconsIcon icon={Delete01Icon} size={12} className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -648,7 +649,7 @@ function EditChannelDialog({
|
|||||||
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
|
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<HugeiconsIcon icon={Delete01Icon} size={12} className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 { useMatchRoute } from '@tanstack/react-router';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Loader2, MessageSquare, Sparkles } from 'lucide-react';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
@@ -187,7 +189,7 @@ export function FloatingGenerateBox({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<motion.div
|
<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' }}
|
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
||||||
>
|
>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -274,7 +276,7 @@ export function FloatingGenerateBox({
|
|||||||
field.ref(node);
|
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"
|
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={{
|
style={{
|
||||||
minHeight: isExpanded ? '100px' : '32px',
|
minHeight: isExpanded ? '100px' : '32px',
|
||||||
@@ -294,18 +296,27 @@ export function FloatingGenerateBox({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
|
<div className="group relative">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || !selectedProfileId}
|
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"
|
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"
|
size="icon"
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Sparkles className="h-4 w-4" />
|
<HugeiconsIcon icon={SparklesIcon} size={16} className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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>
|
<AnimatePresence>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -315,6 +326,7 @@ export function FloatingGenerateBox({
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="absolute top-0 right-[calc(100%+0.5rem)]"
|
className="absolute top-0 right-[calc(100%+0.5rem)]"
|
||||||
>
|
>
|
||||||
|
<div className="group relative">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -327,8 +339,12 @@ export function FloatingGenerateBox({
|
|||||||
: 'bg-card border border-border hover:bg-background/50',
|
: 'bg-card border border-border hover:bg-background/50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageSquare className="h-4 w-4" />
|
<HugeiconsIcon icon={TextSquareIcon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
@@ -46,7 +48,11 @@ export function GenerationForm() {
|
|||||||
<FormLabel>Voice Profile</FormLabel>
|
<FormLabel>Voice Profile</FormLabel>
|
||||||
{selectedProfile ? (
|
{selectedProfile ? (
|
||||||
<div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
|
<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="font-medium">{selectedProfile.name}</span>
|
||||||
<span className="text-sm text-muted-foreground">{selectedProfile.language}</span>
|
<span className="text-sm text-muted-foreground">{selectedProfile.language}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,14 +176,10 @@ export function GenerationForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button type="submit" className="w-full" disabled={isPending || !selectedProfileId}>
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
disabled={isPending || !selectedProfileId}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
{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...
|
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 { useEffect, useRef, useState } from 'react';
|
||||||
import type { HistoryResponse } from '@/lib/api/types';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -19,6 +27,7 @@ import {
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { HistoryResponse } from '@/lib/api/types';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import {
|
import {
|
||||||
useDeleteGeneration,
|
useDeleteGeneration,
|
||||||
@@ -46,11 +55,17 @@ export function HistoryTable() {
|
|||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
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 limit = 20;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: historyData, isLoading, isFetching } = useHistory({
|
const {
|
||||||
|
data: historyData,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useHistory({
|
||||||
limit,
|
limit,
|
||||||
offset: page * limit,
|
offset: page * limit,
|
||||||
});
|
});
|
||||||
@@ -210,7 +225,10 @@ export function HistoryTable() {
|
|||||||
if (isLoading && page === 0) {
|
if (isLoading && page === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -256,7 +274,11 @@ export function HistoryTable() {
|
|||||||
>
|
>
|
||||||
{/* Waveform icon */}
|
{/* Waveform icon */}
|
||||||
<div className="flex items-center shrink-0">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Left side - Meta information */}
|
{/* Left side - Meta information */}
|
||||||
@@ -280,6 +302,7 @@ export function HistoryTable() {
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={gen.text}
|
value={gen.text}
|
||||||
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
||||||
|
readOnly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -297,36 +320,35 @@ export function HistoryTable() {
|
|||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
aria-label="Actions"
|
aria-label="Actions"
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
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
|
Play
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||||
disabled={exportGenerationAudio.isPending}
|
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
|
Export Audio
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleExportPackage(gen.id, gen.text)}
|
onClick={() => handleExportPackage(gen.id, gen.text)}
|
||||||
disabled={exportGeneration.isPending}
|
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
|
Export Package
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||||
disabled={deleteGeneration.isPending}
|
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
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -339,7 +361,12 @@ export function HistoryTable() {
|
|||||||
{/* Load more trigger element */}
|
{/* Load more trigger element */}
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -358,7 +385,8 @@ export function HistoryTable() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Generation</DialogTitle>
|
<DialogTitle>Delete Generation</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<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 { useRef, useState } from 'react';
|
||||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||||
@@ -89,7 +90,7 @@ export function MainEditor() {
|
|||||||
<h2 className="text-2xl font-bold">Voicebox</h2>
|
<h2 className="text-2xl font-bold">Voicebox</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={handleImportClick}>
|
<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
|
Import Voice
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
@@ -100,7 +101,7 @@ export function MainEditor() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<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
|
Create Voice
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Download, Loader2, Trash2 } from 'lucide-react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useState } from 'react';
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -17,7 +19,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
import { ModelProgress } from './ModelProgress';
|
|
||||||
|
|
||||||
export function ModelManagement() {
|
export function ModelManagement() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -27,15 +28,36 @@ export function ModelManagement() {
|
|||||||
|
|
||||||
const { data: modelStatus, isLoading } = useQuery({
|
const { data: modelStatus, isLoading } = useQuery({
|
||||||
queryKey: ['modelStatus'],
|
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
|
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
|
// Use progress toast hook for the downloading model
|
||||||
useModelDownloadToast({
|
useModelDownloadToast({
|
||||||
modelName: downloadingModel || '',
|
modelName: downloadingModel || '',
|
||||||
displayName: downloadingDisplayName || '',
|
displayName: downloadingDisplayName || '',
|
||||||
enabled: !!downloadingModel && !!downloadingDisplayName,
|
enabled: !!downloadingModel && !!downloadingDisplayName,
|
||||||
|
onComplete: handleDownloadComplete,
|
||||||
|
onError: handleDownloadError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@@ -45,44 +67,69 @@ export function ModelManagement() {
|
|||||||
sizeMb?: number;
|
sizeMb?: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const downloadMutation = useMutation({
|
const handleDownload = async (modelName: string) => {
|
||||||
mutationFn: (modelName: string) => {
|
console.log('[Download] Button clicked for:', modelName, 'at', new Date().toISOString());
|
||||||
setDownloadingModel(modelName);
|
|
||||||
// Find display name from model status
|
// Find display name
|
||||||
const model = modelStatus?.models.find((m) => m.model_name === modelName);
|
const model = modelStatus?.models.find((m) => m.model_name === modelName);
|
||||||
setDownloadingDisplayName(model?.display_name || modelName);
|
const displayName = model?.display_name || modelName;
|
||||||
return apiClient.triggerModelDownload(modelName);
|
|
||||||
},
|
try {
|
||||||
onSuccess: () => {
|
// IMPORTANT: Call the API FIRST before setting state
|
||||||
// Download completed - clear state and refetch status
|
// Setting state enables the SSE EventSource in useModelDownloadToast,
|
||||||
setDownloadingModel(null);
|
// which can block/delay the download fetch due to HTTP/1.1 connection limits
|
||||||
setDownloadingDisplayName(null);
|
console.log('[Download] Calling download API for:', modelName);
|
||||||
|
const result = await apiClient.triggerModelDownload(modelName);
|
||||||
|
console.log('[Download] Download API responded:', result);
|
||||||
|
|
||||||
|
// NOW set state to enable SSE tracking (after download has started on backend)
|
||||||
|
setDownloadingModel(modelName);
|
||||||
|
setDownloadingDisplayName(displayName);
|
||||||
|
|
||||||
|
// Download initiated successfully - state will be cleared when SSE reports completion
|
||||||
|
// or by the polling interval detecting the model is downloaded
|
||||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||||
},
|
} catch (error) {
|
||||||
onError: (error: Error) => {
|
console.error('[Download] Download failed:', error);
|
||||||
setDownloadingModel(null);
|
setDownloadingModel(null);
|
||||||
setDownloadingDisplayName(null);
|
setDownloadingDisplayName(null);
|
||||||
toast({
|
toast({
|
||||||
title: 'Download failed',
|
title: 'Download failed',
|
||||||
description: error.message,
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (modelName: string) => apiClient.deleteModel(modelName),
|
mutationFn: async (modelName: string) => {
|
||||||
onSuccess: () => {
|
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({
|
toast({
|
||||||
title: 'Model deleted',
|
title: 'Model deleted',
|
||||||
description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`,
|
description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`,
|
||||||
});
|
});
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setModelToDelete(null);
|
setModelToDelete(null);
|
||||||
// Refetch status to update UI
|
// Invalidate AND explicitly refetch to ensure UI updates
|
||||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
// 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) => {
|
onError: (error: Error) => {
|
||||||
|
console.log('[Delete] onError:', error);
|
||||||
toast({
|
toast({
|
||||||
title: 'Delete failed',
|
title: 'Delete failed',
|
||||||
description: error.message,
|
description: error.message,
|
||||||
@@ -108,7 +155,10 @@ export function ModelManagement() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<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>
|
</div>
|
||||||
) : modelStatus ? (
|
) : modelStatus ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -124,7 +174,7 @@ export function ModelManagement() {
|
|||||||
<ModelItem
|
<ModelItem
|
||||||
key={model.model_name}
|
key={model.model_name}
|
||||||
model={model}
|
model={model}
|
||||||
onDownload={() => downloadMutation.mutate(model.model_name)}
|
onDownload={() => handleDownload(model.model_name)}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
setModelToDelete({
|
setModelToDelete({
|
||||||
name: model.model_name,
|
name: model.model_name,
|
||||||
@@ -152,7 +202,7 @@ export function ModelManagement() {
|
|||||||
<ModelItem
|
<ModelItem
|
||||||
key={model.model_name}
|
key={model.model_name}
|
||||||
model={model}
|
model={model}
|
||||||
onDownload={() => downloadMutation.mutate(model.model_name)}
|
onDownload={() => handleDownload(model.model_name)}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
setModelToDelete({
|
setModelToDelete({
|
||||||
name: model.model_name,
|
name: model.model_name,
|
||||||
@@ -167,22 +217,6 @@ export function ModelManagement() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -216,7 +250,7 @@ export function ModelManagement() {
|
|||||||
>
|
>
|
||||||
{deleteMutation.isPending ? (
|
{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...
|
Deleting...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -235,16 +269,20 @@ interface ModelItemProps {
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
|
downloading?: boolean; // From server - true if download in progress
|
||||||
size_mb?: number;
|
size_mb?: number;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
};
|
};
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean; // Local state - true if user just clicked download
|
||||||
formatSize: (sizeMb?: number) => string;
|
formatSize: (sizeMb?: number) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -255,20 +293,21 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
|
|||||||
Loaded
|
Loaded
|
||||||
</Badge>
|
</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">
|
<Badge variant="secondary" className="text-xs">
|
||||||
Downloaded
|
Downloaded
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{model.downloaded && model.size_mb && (
|
{model.downloaded && model.size_mb && !showDownloading && (
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
Size: {formatSize(model.size_mb)}
|
Size: {formatSize(model.size_mb)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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-2">
|
||||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<span>Ready</span>
|
<span>Ready</span>
|
||||||
@@ -280,22 +319,18 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
|
|||||||
disabled={model.loaded}
|
disabled={model.loaded}
|
||||||
title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : showDownloading ? (
|
||||||
<Button size="sm" onClick={onDownload} disabled={isDownloading} variant="outline">
|
<Button size="sm" variant="outline" disabled>
|
||||||
{isDownloading ? (
|
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 mr-2 animate-spin" />
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
Downloading...
|
Downloading...
|
||||||
</>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Button size="sm" onClick={onDownload} variant="outline">
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||||
Download
|
Download
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { useEffect, useState } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
@@ -8,14 +10,27 @@ import { useServerStore } from '@/stores/serverStore';
|
|||||||
interface ModelProgressProps {
|
interface ModelProgressProps {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
displayName: 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 [progress, setProgress] = useState<ModelProgressType | null>(null);
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
// Subscribe to progress updates via Server-Sent Events
|
||||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||||
@@ -27,6 +42,7 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
|
|
||||||
// Close connection if complete or error
|
// Close connection if complete or error
|
||||||
if (data.status === 'complete' || data.status === 'error') {
|
if (data.status === 'complete' || data.status === 'error') {
|
||||||
|
console.log(`[ModelProgress] Download ${data.status} for ${modelName}, closing SSE`);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -35,14 +51,15 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error('SSE error:', error);
|
console.error(`[ModelProgress] SSE error for ${modelName}:`, error);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log(`[ModelProgress] Cleanup - closing SSE for ${modelName}`);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
};
|
};
|
||||||
}, [serverUrl, modelName]);
|
}, [serverUrl, modelName, isDownloading]);
|
||||||
|
|
||||||
// Don't render if no progress or if complete/error and some time has passed
|
// Don't render if no progress or if complete/error and some time has passed
|
||||||
if (
|
if (
|
||||||
@@ -63,10 +80,12 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
const getStatusIcon = () => {
|
const getStatusIcon = () => {
|
||||||
switch (progress.status) {
|
switch (progress.status) {
|
||||||
case 'error':
|
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 'downloading':
|
||||||
case 'extracting':
|
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:
|
default:
|
||||||
return null;
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||||
@@ -32,12 +34,12 @@ export function ServerStatus() {
|
|||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span className="text-sm">Checking connection...</span>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span className="text-sm text-destructive">Connection failed: {error.message}</span>
|
||||||
</div>
|
</div>
|
||||||
) : health ? (
|
) : 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 { useEffect, useState } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -13,9 +14,10 @@ export function UpdateStatus() {
|
|||||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
platform.metadata.getVersion()
|
platform.metadata
|
||||||
|
.getVersion()
|
||||||
.then(setCurrentVersion)
|
.then(setCurrentVersion)
|
||||||
.catch(() => setCurrentVersion('0.1.0'));
|
.catch(() => setCurrentVersion('Unknown'));
|
||||||
}, [platform]);
|
}, [platform]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,21 +37,21 @@ export function UpdateStatus() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
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
|
Check for Updates
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status.checking && (
|
{status.checking && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<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...
|
Checking for updates...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status.error && (
|
{status.error && (
|
||||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
<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}
|
{status.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -64,7 +66,7 @@ export function UpdateStatus() {
|
|||||||
<Badge>New</Badge>
|
<Badge>New</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={downloadAndInstall} className="w-full" size="sm">
|
<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
|
Download Update
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,7 +76,7 @@ export function UpdateStatus() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<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...
|
Downloading update...
|
||||||
</div>
|
</div>
|
||||||
{status.downloadProgress !== undefined && (
|
{status.downloadProgress !== undefined && (
|
||||||
@@ -108,7 +110,7 @@ export function UpdateStatus() {
|
|||||||
your convenience.
|
your convenience.
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={restartAndInstall} className="w-full" size="sm">
|
<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
|
Restart Now
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
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 { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
||||||
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
@@ -11,6 +13,8 @@ export function ServerTab() {
|
|||||||
<ConnectionForm />
|
<ConnectionForm />
|
||||||
<ServerStatus />
|
<ServerStatus />
|
||||||
</div>
|
</div>
|
||||||
|
<ProviderSettings />
|
||||||
|
<DataFolders />
|
||||||
{platform.metadata.isTauri && <UpdateStatus />}
|
{platform.metadata.isTauri && <UpdateStatus />}
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
Created by{' '}
|
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 { 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 voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
@@ -10,12 +19,12 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
|
{ id: 'main', path: '/', icon: VolumeHighIcon, label: 'Generate' },
|
||||||
{ id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' },
|
{ id: 'stories', path: '/stories', icon: Book01Icon, label: 'Stories' },
|
||||||
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
|
{ id: 'voices', path: '/voices', icon: Mic01Icon, label: 'Voices' },
|
||||||
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
|
{ id: 'audio', path: '/audio', icon: SpeakerIcon, label: 'Audio' },
|
||||||
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
|
{ id: 'models', path: '/models', icon: PackageIcon, label: 'Models' },
|
||||||
{ id: 'server', path: '/server', icon: Server, label: 'Server' },
|
{ id: 'server', path: '/server', icon: ServerStack01Icon, label: 'Server' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ isMacOS }: SidebarProps) {
|
export function Sidebar({ isMacOS }: SidebarProps) {
|
||||||
@@ -42,9 +51,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
|||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
// For index route, use exact match; for others, use default matching
|
// For index route, use exact match; for others, use default matching
|
||||||
const isActive =
|
const isActive =
|
||||||
tab.path === '/'
|
tab.path === '/' ? matchRoute({ to: '/' }) : matchRoute({ to: tab.path });
|
||||||
? matchRoute({ to: '/', exact: true })
|
|
||||||
: matchRoute({ to: tab.path });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -58,7 +65,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
|||||||
title={tab.label}
|
title={tab.label}
|
||||||
aria-label={tab.label}
|
aria-label={tab.label}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
<HugeiconsIcon icon={Icon} size={20} className="h-5 w-5" />
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -75,7 +82,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
|||||||
isPlayerVisible ? 'mb-[120px]' : 'mb-0',
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
import { useState } from 'react';
|
import { DragDropVerticalIcon, MoreHorizontalIcon, PlayIcon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -10,10 +10,10 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { ProfileAvatar } from '@/components/VoiceProfiles/ProfileAvatar';
|
||||||
import type { StoryItemDetail } from '@/lib/api/types';
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
|
|
||||||
interface StoryChatItemProps {
|
interface StoryChatItemProps {
|
||||||
item: StoryItemDetail;
|
item: StoryItemDetail;
|
||||||
@@ -35,10 +35,6 @@ export function StoryChatItem({
|
|||||||
isDragging,
|
isDragging,
|
||||||
}: StoryChatItemProps) {
|
}: StoryChatItemProps) {
|
||||||
const seek = useStoryStore((state) => state.seek);
|
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
|
// Check if this item is currently playing based on timecode
|
||||||
const itemStartMs = item.start_time_ms;
|
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"
|
className="shrink-0 cursor-grab active:cursor-grabbing touch-none text-muted-foreground hover:text-foreground transition-colors"
|
||||||
{...dragHandleProps}
|
{...dragHandleProps}
|
||||||
>
|
>
|
||||||
<GripVertical className="h-5 w-5" />
|
<HugeiconsIcon icon={DragDropVerticalIcon} size={20} className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Voice Avatar */}
|
{/* Voice Avatar */}
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
|
<ProfileAvatar
|
||||||
{!avatarError ? (
|
profileId={item.profile_id}
|
||||||
<img
|
size="lg"
|
||||||
src={avatarUrl}
|
grayscale={!isCurrentlyPlaying}
|
||||||
alt={`${item.profile_name} avatar`}
|
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -119,16 +106,16 @@ export function StoryChatItem({
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Actions">
|
<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>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={handlePlay}>
|
<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
|
Play from here
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={onRemove} className="text-destructive focus:text-destructive">
|
<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
|
Remove from Story
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} 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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -271,7 +272,7 @@ export function StoryContent() {
|
|||||||
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<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
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -316,7 +317,7 @@ export function StoryContent() {
|
|||||||
onClick={handleExportAudio}
|
onClick={handleExportAudio}
|
||||||
disabled={exportAudio.isPending}
|
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
|
Export Audio
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Plus, BookOpen, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
import { useState } from 'react';
|
import { Add01Icon, Book01Icon, MoreHorizontalIcon, PencilIcon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -29,7 +30,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
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 { cn } from '@/lib/utils/cn';
|
||||||
import { formatDate } from '@/lib/utils/format';
|
import { formatDate } from '@/lib/utils/format';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
@@ -38,6 +39,8 @@ export function StoryList() {
|
|||||||
const { data: stories, isLoading } = useStories();
|
const { data: stories, isLoading } = useStories();
|
||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||||
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
|
const { data: currentStory } = useStory(selectedStoryId);
|
||||||
const createStory = useCreateStory();
|
const createStory = useCreateStory();
|
||||||
const updateStory = useUpdateStory();
|
const updateStory = useUpdateStory();
|
||||||
const deleteStory = useDeleteStory();
|
const deleteStory = useDeleteStory();
|
||||||
@@ -54,6 +57,16 @@ export function StoryList() {
|
|||||||
const [newStoryDescription, setNewStoryDescription] = useState('');
|
const [newStoryDescription, setNewStoryDescription] = useState('');
|
||||||
const { toast } = useToast();
|
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 = () => {
|
const handleCreateStory = () => {
|
||||||
if (!newStoryName.trim()) {
|
if (!newStoryName.trim()) {
|
||||||
toast({
|
toast({
|
||||||
@@ -177,16 +190,19 @@ export function StoryList() {
|
|||||||
<div className="flex items-center justify-between mb-4 px-1">
|
<div className="flex items-center justify-between mb-4 px-1">
|
||||||
<h2 className="text-2xl font-bold">Stories</h2>
|
<h2 className="text-2xl font-bold">Stories</h2>
|
||||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
<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
|
New Story
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Story List */}
|
{/* 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 ? (
|
{storyList.length === 0 ? (
|
||||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
<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-sm">No stories yet</p>
|
||||||
<p className="text-xs mt-2">Create your first story to get started</p>
|
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,19 +243,19 @@ export function StoryList() {
|
|||||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
<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
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDeleteClick(story.id)}
|
onClick={() => handleDeleteClick(story.id)}
|
||||||
className="text-destructive focus:text-destructive"
|
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
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy01Icon,
|
||||||
GripHorizontal,
|
DragDropHorizontalIcon,
|
||||||
Minus,
|
RemoveIcon,
|
||||||
Pause,
|
PauseIcon,
|
||||||
Play,
|
PlayIcon,
|
||||||
Plus,
|
Add01Icon,
|
||||||
Scissors,
|
Scissor01Icon,
|
||||||
Square,
|
SquareIcon,
|
||||||
Trash2,
|
Delete01Icon,
|
||||||
} from 'lucide-react';
|
} from '@hugeicons/core-free-icons';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -723,7 +724,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
onMouseDown={handleResizeStart}
|
onMouseDown={handleResizeStart}
|
||||||
aria-label="Resize track editor"
|
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>
|
</button>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
@@ -737,7 +738,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
onClick={handlePlayPause}
|
onClick={handlePlayPause}
|
||||||
title="Play/Pause (Space)"
|
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>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -746,7 +747,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
disabled={!isCurrentlyPlaying}
|
disabled={!isCurrentlyPlaying}
|
||||||
>
|
>
|
||||||
<Square className="h-3 w-3" />
|
<HugeiconsIcon icon={SquareIcon} size={12} className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-muted-foreground tabular-nums ml-2">
|
<span className="text-xs text-muted-foreground tabular-nums ml-2">
|
||||||
{formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
|
{formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
|
||||||
@@ -763,7 +764,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
onClick={handleSplit}
|
onClick={handleSplit}
|
||||||
title="Split at playhead (S)"
|
title="Split at playhead (S)"
|
||||||
>
|
>
|
||||||
<Scissors className="h-4 w-4" />
|
<HugeiconsIcon icon={Scissor01Icon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -772,7 +773,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
onClick={handleDuplicate}
|
onClick={handleDuplicate}
|
||||||
title="Duplicate (Cmd/Ctrl+D)"
|
title="Duplicate (Cmd/Ctrl+D)"
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<HugeiconsIcon icon={Copy01Icon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -781,7 +782,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
title="Delete (Delete/Backspace)"
|
title="Delete (Delete/Backspace)"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -790,10 +791,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">Zoom:</span>
|
<span className="text-xs text-muted-foreground">Zoom:</span>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
|
<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>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -837,7 +838,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
|
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
|
||||||
style={{ width: `${timelineWidth}px` }}
|
style={{ width: `${timelineWidth}px` }}
|
||||||
onClick={handleTimelineClick}
|
onClick={(e) => handleTimelineClick(e as unknown as React.MouseEvent<HTMLDivElement>)}
|
||||||
aria-label="Seek timeline"
|
aria-label="Seek timeline"
|
||||||
>
|
>
|
||||||
{timeMarkers.map((ms) => (
|
{timeMarkers.map((ms) => (
|
||||||
@@ -878,7 +879,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute inset-0 z-0 cursor-pointer"
|
className="absolute inset-0 z-0 cursor-pointer"
|
||||||
onClick={handleTimelineClick}
|
onClick={(e) => handleTimelineClick(e as unknown as React.MouseEvent<HTMLDivElement>)}
|
||||||
aria-label="Seek timeline"
|
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 { memo, useEffect, useState } from 'react';
|
||||||
import { Visualizer } from 'react-sound-visualizer';
|
import { Visualizer } from 'react-sound-visualizer';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -95,7 +96,7 @@ export function AudioSampleRecording({
|
|||||||
size="lg"
|
size="lg"
|
||||||
className="relative z-10 flex items-center gap-2"
|
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
|
Start Recording
|
||||||
</Button>
|
</Button>
|
||||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||||
@@ -122,7 +123,7 @@ export function AudioSampleRecording({
|
|||||||
onClick={onStop}
|
onClick={onStop}
|
||||||
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
|
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
|
Stop Recording
|
||||||
</Button>
|
</Button>
|
||||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||||
@@ -134,13 +135,13 @@ export function AudioSampleRecording({
|
|||||||
{file && !isRecording && (
|
{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 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">
|
<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>
|
<span className="font-medium">Recording complete</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
|
<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>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -149,7 +150,7 @@ export function AudioSampleRecording({
|
|||||||
disabled={isTranscribing}
|
disabled={isTranscribing}
|
||||||
className="flex items-center gap-2"
|
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'}
|
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||||
</Button>
|
</Button>
|
||||||
<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 { Button } from '@/components/ui/button';
|
||||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
@@ -35,7 +36,7 @@ export function AudioSampleSystem({
|
|||||||
{!isRecording && !file && (
|
{!isRecording && !file && (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
<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">
|
<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
|
Start Capture
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
@@ -60,7 +61,7 @@ export function AudioSampleSystem({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Square className="h-4 w-4" />
|
<HugeiconsIcon icon={SquareIcon} size={16} className="h-4 w-4" />
|
||||||
Stop Capture
|
Stop Capture
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
@@ -72,13 +73,13 @@ export function AudioSampleSystem({
|
|||||||
{file && !isRecording && (
|
{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 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">
|
<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>
|
<span className="font-medium">Capture complete</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
|
<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>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -87,7 +88,7 @@ export function AudioSampleSystem({
|
|||||||
disabled={isTranscribing}
|
disabled={isTranscribing}
|
||||||
className="flex items-center gap-2"
|
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'}
|
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||||
</Button>
|
</Button>
|
||||||
<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 { useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
@@ -89,7 +90,7 @@ export function AudioSampleUpload({
|
|||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Upload className="h-5 w-5" />
|
<HugeiconsIcon icon={Upload01Icon} size={20} className="h-5 w-5" />
|
||||||
Choose File
|
Choose File
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
@@ -99,7 +100,7 @@ export function AudioSampleUpload({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span className="font-medium">File uploaded</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
@@ -111,7 +112,7 @@ export function AudioSampleUpload({
|
|||||||
onClick={onPlayPause}
|
onClick={onPlayPause}
|
||||||
disabled={isValidating}
|
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>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -120,7 +121,7 @@ export function AudioSampleUpload({
|
|||||||
disabled={isTranscribing || isValidating || isDisabled}
|
disabled={isTranscribing || isValidating || isDisabled}
|
||||||
className="flex items-center gap-2"
|
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'}
|
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||||
</Button>
|
</Button>
|
||||||
<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 { useState } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -12,10 +13,10 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import { ProfileAvatar } from '@/components/VoiceProfiles/ProfileAvatar';
|
||||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||||
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
|
||||||
interface ProfileCardProps {
|
interface ProfileCardProps {
|
||||||
@@ -24,19 +25,15 @@ interface ProfileCardProps {
|
|||||||
|
|
||||||
export function ProfileCard({ profile }: ProfileCardProps) {
|
export function ProfileCard({ profile }: ProfileCardProps) {
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [avatarError, setAvatarError] = useState(false);
|
|
||||||
const deleteProfile = useDeleteProfile();
|
const deleteProfile = useDeleteProfile();
|
||||||
const exportProfile = useExportProfile();
|
const exportProfile = useExportProfile();
|
||||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||||
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||||
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
|
||||||
|
|
||||||
const isSelected = selectedProfileId === profile.id;
|
const isSelected = selectedProfileId === profile.id;
|
||||||
|
|
||||||
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
|
|
||||||
|
|
||||||
const handleSelect = () => {
|
const handleSelect = () => {
|
||||||
setSelectedProfileId(isSelected ? null : profile.id);
|
setSelectedProfileId(isSelected ? null : profile.id);
|
||||||
};
|
};
|
||||||
@@ -72,21 +69,13 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
>
|
>
|
||||||
<CardHeader className="p-3 pb-2">
|
<CardHeader className="p-3 pb-2">
|
||||||
<CardTitle className="flex items-center gap-1.5 text-base font-medium">
|
<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">
|
<ProfileAvatar
|
||||||
{avatarUrl && !avatarError ? (
|
profileId={profile.id}
|
||||||
<img
|
avatarPath={profile.avatar_path}
|
||||||
src={avatarUrl}
|
size="sm"
|
||||||
|
grayscale={!isSelected}
|
||||||
alt={`${profile.name} avatar`}
|
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>
|
|
||||||
<span className="break-words">{profile.name}</span>
|
<span className="break-words">{profile.name}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -101,13 +90,13 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Download}
|
icon={(props) => <HugeiconsIcon icon={Download01Icon} size={14} {...props} />}
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
disabled={exportProfile.isPending}
|
disabled={exportProfile.isPending}
|
||||||
aria-label="Export profile"
|
aria-label="Export profile"
|
||||||
/>
|
/>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Edit}
|
icon={(props) => <HugeiconsIcon icon={Edit01Icon} size={14} {...props} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleEdit();
|
handleEdit();
|
||||||
@@ -115,7 +104,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
aria-label="Edit profile"
|
aria-label="Edit profile"
|
||||||
/>
|
/>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Trash2}
|
icon={(props) => <HugeiconsIcon icon={Delete01Icon} size={14} {...props} />}
|
||||||
onClick={handleDeleteClick}
|
onClick={handleDeleteClick}
|
||||||
disabled={deleteProfile.isPending}
|
disabled={deleteProfile.isPending}
|
||||||
aria-label="Delete profile"
|
aria-label="Delete profile"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
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 { useEffect, useRef, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
@@ -635,7 +636,7 @@ export function ProfileForm() {
|
|||||||
setSampleMode('record');
|
setSampleMode('record');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3 mr-1" />
|
<HugeiconsIcon icon={Cancel01Icon} size={12} className="h-3 w-3 mr-1" />
|
||||||
Discard
|
Discard
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -668,16 +669,16 @@ export function ProfileForm() {
|
|||||||
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
<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
|
Upload
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
<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
|
Record
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
<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
|
System Audio
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
@@ -798,7 +799,7 @@ export function ProfileForm() {
|
|||||||
className="h-full w-full object-cover"
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -806,7 +807,7 @@ export function ProfileForm() {
|
|||||||
onClick={() => avatarInputRef.current?.click()}
|
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"
|
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>
|
</button>
|
||||||
{(avatarPreview || editingProfile?.avatar_path) && (
|
{(avatarPreview || editingProfile?.avatar_path) && (
|
||||||
<button
|
<button
|
||||||
@@ -815,7 +816,7 @@ export function ProfileForm() {
|
|||||||
disabled={deleteAvatar.isPending}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
@@ -30,12 +31,12 @@ export function ProfileList() {
|
|||||||
{allProfiles.length === 0 ? (
|
{allProfiles.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<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">
|
<p className="text-muted-foreground mb-4">
|
||||||
No voice profiles yet. Create your first profile to get started.
|
No voice profiles yet. Create your first profile to get started.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<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
|
Create Voice
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</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 { useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CircleButton } from '@/components/ui/circle-button';
|
import { CircleButton } from '@/components/ui/circle-button';
|
||||||
@@ -103,7 +104,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
|||||||
onClick={handlePlayPause}
|
onClick={handlePlayPause}
|
||||||
disabled={isLoading}
|
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>
|
</Button>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
@@ -129,7 +130,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
|||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
title="Stop"
|
title="Stop"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<HugeiconsIcon icon={Cancel01Icon} size={14} className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,7 +210,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
<div className="space-y-4 pt-4">
|
<div className="space-y-4 pt-4">
|
||||||
{samples && samples.length === 0 ? (
|
{samples && samples.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
<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-sm text-muted-foreground">No samples yet</p>
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
Add your first audio sample to get started
|
Add your first audio sample to get started
|
||||||
@@ -232,7 +233,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
/* Edit Mode */
|
/* Edit Mode */
|
||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
<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>
|
<span>Editing transcription</span>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -250,7 +251,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
onClick={handleCancelEdit}
|
onClick={handleCancelEdit}
|
||||||
disabled={updateSample.isPending}
|
disabled={updateSample.isPending}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 mr-1" />
|
<HugeiconsIcon icon={Cancel01Icon} size={16} className="h-4 w-4 mr-1" />
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -259,7 +260,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
onClick={() => handleSaveEdit(sample.id)}
|
onClick={() => handleSaveEdit(sample.id)}
|
||||||
disabled={updateSample.isPending}
|
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'}
|
{updateSample.isPending ? 'Saving...' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,12 +279,12 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Edit}
|
icon={(props) => <HugeiconsIcon icon={Edit01Icon} size={14} {...props} />}
|
||||||
title="Edit transcription"
|
title="Edit transcription"
|
||||||
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
||||||
/>
|
/>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Trash2}
|
icon={(props) => <HugeiconsIcon icon={Delete01Icon} size={14} {...props} />}
|
||||||
title="Delete sample"
|
title="Delete sample"
|
||||||
onClick={() => handleDeleteClick(sample.id)}
|
onClick={() => handleDeleteClick(sample.id)}
|
||||||
disabled={deleteSample.isPending}
|
disabled={deleteSample.isPending}
|
||||||
@@ -312,7 +313,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => setUploadOpen(true)}
|
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
|
Add Sample
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
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 { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
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'}`}
|
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="upload" className="flex items-center gap-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
|
Upload
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
<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
|
Record
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
<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
|
System Audio
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
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 { useMemo, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
|
import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
|
||||||
|
import { ProfileAvatar } from '@/components/VoiceProfiles/ProfileAvatar';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
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">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold">Voices</h1>
|
<h1 className="text-2xl font-bold">Voices</h1>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<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
|
New Voice
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,9 +186,12 @@ function VoiceRow({
|
|||||||
<TableRow className="cursor-pointer" onClick={onEdit}>
|
<TableRow className="cursor-pointer" onClick={onEdit}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
<ProfileAvatar
|
||||||
<Mic className="h-4 w-4 text-muted-foreground" />
|
profileId={profile.id}
|
||||||
</div>
|
avatarPath={profile.avatar_path}
|
||||||
|
size="md"
|
||||||
|
alt={`${profile.name} avatar`}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{profile.name}</div>
|
<div className="font-medium">{profile.name}</div>
|
||||||
{profile.description && (
|
{profile.description && (
|
||||||
@@ -214,16 +219,16 @@ function VoiceRow({
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem onClick={onEdit}>
|
<DropdownMenuItem onClick={onEdit}>
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
<HugeiconsIcon icon={Edit01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
<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
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
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';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
export interface CheckboxProps {
|
export interface CheckboxProps {
|
||||||
@@ -34,7 +35,7 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...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>
|
</button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
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 * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ const DialogContent = React.forwardRef<
|
|||||||
>
|
>
|
||||||
{children}
|
{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">
|
<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>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</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 * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
import { MoreHorizontal } from 'lucide-react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
@@ -26,7 +27,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<MoreHorizontal className="ml-auto h-4 w-4" />
|
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="ml-auto h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
));
|
));
|
||||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
@@ -73,7 +74,7 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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',
|
inset && 'pl-8',
|
||||||
className,
|
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">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{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">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<MoreHorizontal className="h-2 w-2 fill-current" />
|
<HugeiconsIcon icon={MoreHorizontalIcon} size={8} className="h-2 w-2 fill-current" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
@@ -154,7 +155,9 @@ const DropdownMenuSeparator = React.forwardRef<
|
|||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
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';
|
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
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 { cn } from '@/lib/utils/cn';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
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">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<Check className="h-4 w-4" />
|
<HugeiconsIcon icon={CheckmarkCircle01Icon} size={16} className="h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
@@ -78,7 +79,7 @@ export function MultiSelect({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="line-clamp-1">{displayText}</span>
|
<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>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<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 * 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 * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
<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.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
));
|
));
|
||||||
@@ -38,7 +39,7 @@ const SelectScrollUpButton = React.forwardRef<
|
|||||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<HugeiconsIcon icon={ArrowUp01Icon} size={16} className="h-4 w-4" />
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
));
|
));
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
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)}
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<HugeiconsIcon icon={ArrowDown01Icon} size={16} className="h-4 w-4" />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
));
|
));
|
||||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
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">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<Check className="h-4 w-4" />
|
<HugeiconsIcon icon={CheckmarkCircle01Icon} size={16} className="h-4 w-4" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
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 * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ const ToastClose = React.forwardRef<
|
|||||||
toast-close=""
|
toast-close=""
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<HugeiconsIcon icon={Cancel01Icon} size={16} className="h-4 w-4" />
|
||||||
</ToastPrimitives.Close>
|
</ToastPrimitives.Close>
|
||||||
));
|
));
|
||||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
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 type { LanguageCode } from '@/lib/constants/languages';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import type {
|
import type {
|
||||||
VoiceProfileCreate,
|
ActiveTasksResponse,
|
||||||
VoiceProfileResponse,
|
FolderPathsResponse,
|
||||||
ProfileSampleResponse,
|
|
||||||
GenerationRequest,
|
GenerationRequest,
|
||||||
GenerationResponse,
|
GenerationResponse,
|
||||||
HistoryQuery,
|
|
||||||
HistoryListResponse,
|
|
||||||
HistoryResponse,
|
|
||||||
TranscriptionResponse,
|
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
ModelStatusListResponse,
|
HistoryListResponse,
|
||||||
|
HistoryQuery,
|
||||||
|
HistoryResponse,
|
||||||
ModelDownloadRequest,
|
ModelDownloadRequest,
|
||||||
ActiveTasksResponse,
|
ModelStatusListResponse,
|
||||||
|
ProfileSampleResponse,
|
||||||
StoryCreate,
|
StoryCreate,
|
||||||
StoryResponse,
|
|
||||||
StoryDetailResponse,
|
StoryDetailResponse,
|
||||||
|
StoryItemBatchUpdate,
|
||||||
StoryItemCreate,
|
StoryItemCreate,
|
||||||
StoryItemDetail,
|
StoryItemDetail,
|
||||||
StoryItemBatchUpdate,
|
|
||||||
StoryItemReorder,
|
|
||||||
StoryItemMove,
|
StoryItemMove,
|
||||||
StoryItemTrim,
|
StoryItemReorder,
|
||||||
StoryItemSplit,
|
StoryItemSplit,
|
||||||
|
StoryItemTrim,
|
||||||
|
StoryResponse,
|
||||||
|
TranscriptionResponse,
|
||||||
|
VoiceProfileCreate,
|
||||||
|
VoiceProfileResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
@@ -57,6 +58,11 @@ class ApiClient {
|
|||||||
return this.request<HealthResponse>('/health');
|
return this.request<HealthResponse>('/health');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System
|
||||||
|
async getSystemFolders(): Promise<FolderPathsResponse> {
|
||||||
|
return this.request<FolderPathsResponse>('/system/folders');
|
||||||
|
}
|
||||||
|
|
||||||
// Profiles
|
// Profiles
|
||||||
async createProfile(data: VoiceProfileCreate): Promise<VoiceProfileResponse> {
|
async createProfile(data: VoiceProfileCreate): Promise<VoiceProfileResponse> {
|
||||||
return this.request<VoiceProfileResponse>('/profiles', {
|
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
|
// History
|
||||||
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -251,7 +328,15 @@ class ApiClient {
|
|||||||
return response.blob();
|
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 url = `${this.getBaseUrl()}/history/import`;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
@@ -310,10 +395,18 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
|
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',
|
method: 'POST',
|
||||||
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
||||||
});
|
});
|
||||||
|
console.log('[API] triggerModelDownload response:', result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteModel(modelName: string): Promise<{ message: string }> {
|
async deleteModel(modelName: string): Promise<{ message: string }> {
|
||||||
@@ -340,10 +433,7 @@ class ApiClient {
|
|||||||
return this.request('/channels');
|
return this.request('/channels');
|
||||||
}
|
}
|
||||||
|
|
||||||
async createChannel(data: {
|
async createChannel(data: { name: string; device_ids: string[] }): Promise<{
|
||||||
name: string;
|
|
||||||
device_ids: string[];
|
|
||||||
}): Promise<{
|
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
@@ -385,10 +475,7 @@ class ApiClient {
|
|||||||
return this.request(`/channels/${channelId}/voices`);
|
return this.request(`/channels/${channelId}/voices`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setChannelVoices(
|
async setChannelVoices(channelId: string, profileIds: string[]): Promise<{ message: string }> {
|
||||||
channelId: string,
|
|
||||||
profileIds: string[],
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
return this.request(`/channels/${channelId}/voices`, {
|
return this.request(`/channels/${channelId}/voices`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ profile_ids: profileIds }),
|
body: JSON.stringify({ profile_ids: profileIds }),
|
||||||
@@ -399,10 +486,7 @@ class ApiClient {
|
|||||||
return this.request(`/profiles/${profileId}/channels`);
|
return this.request(`/profiles/${profileId}/channels`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setProfileChannels(
|
async setProfileChannels(profileId: string, channelIds: string[]): Promise<{ message: string }> {
|
||||||
profileId: string,
|
|
||||||
channelIds: string[],
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
return this.request(`/profiles/${profileId}/channels`, {
|
return this.request(`/profiles/${profileId}/channels`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ channel_ids: channelIds }),
|
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`, {
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
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`, {
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/trim`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
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`, {
|
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/${itemId}/split`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type ModelStatus = {
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
|
downloading?: boolean; // True if download is in progress
|
||||||
size_mb?: number | null;
|
size_mb?: number | null;
|
||||||
loaded?: boolean;
|
loaded?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface GenerationRequest {
|
|||||||
language: LanguageCode;
|
language: LanguageCode;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
model_size?: '1.7B' | '0.6B';
|
model_size?: '1.7B' | '0.6B';
|
||||||
|
instruct?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerationResponse {
|
export interface GenerationResponse {
|
||||||
@@ -96,6 +97,7 @@ export interface ModelStatus {
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
|
downloading: boolean; // True if download is in progress
|
||||||
size_mb?: number;
|
size_mb?: number;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
}
|
}
|
||||||
@@ -126,6 +128,12 @@ export interface ActiveTasksResponse {
|
|||||||
generations: ActiveGenerationTask[];
|
generations: ActiveGenerationTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FolderPathsResponse {
|
||||||
|
data_dir: string;
|
||||||
|
models_dir: string;
|
||||||
|
providers_dir: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoryCreate {
|
export interface StoryCreate {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|||||||
@@ -91,11 +91,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
instruct: data.instruct || undefined,
|
instruct: data.instruct || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Generation complete!',
|
|
||||||
description: `Audio generated (${result.duration.toFixed(2)}s)`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const audioUrl = apiClient.getAudioUrl(result.id);
|
const audioUrl = apiClient.getAudioUrl(result.id);
|
||||||
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { CancelCircleIcon, CheckmarkCircle02Icon } from '@hugeicons/core-free-icons';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { Icon } from '@iconify/react';
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { Progress } from '@/components/ui/progress';
|
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 type { ModelProgress } from '@/lib/api/types';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
interface UseModelDownloadToastOptions {
|
interface UseModelDownloadToastOptions {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
onComplete?: () => void;
|
||||||
|
onError?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,47 +23,64 @@ export function useModelDownloadToast({
|
|||||||
modelName,
|
modelName,
|
||||||
displayName,
|
displayName,
|
||||||
enabled = false,
|
enabled = false,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
}: UseModelDownloadToastOptions) {
|
}: UseModelDownloadToastOptions) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
const toastIdRef = useRef<string | null>(null);
|
const toastIdRef = useRef<string | null>(null);
|
||||||
const toastUpdateRef = useRef<
|
// biome-ignore lint: Using any for toast update ref to handle complex toast types
|
||||||
((props: {
|
const toastUpdateRef = useRef<any>(null);
|
||||||
title?: React.ReactNode;
|
|
||||||
description?: React.ReactNode;
|
|
||||||
duration?: number;
|
|
||||||
variant?: 'default' | 'destructive';
|
|
||||||
open?: boolean;
|
|
||||||
}) => void) | null
|
|
||||||
>(null);
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
const formatBytes = (bytes: number): string => {
|
const formatBytes = useCallback((bytes: number): string => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
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(() => {
|
useEffect(() => {
|
||||||
|
console.log('[useModelDownloadToast] useEffect triggered', {
|
||||||
|
enabled,
|
||||||
|
serverUrl,
|
||||||
|
modelName,
|
||||||
|
displayName,
|
||||||
|
});
|
||||||
|
|
||||||
if (!enabled || !serverUrl || !modelName) {
|
if (!enabled || !serverUrl || !modelName) {
|
||||||
|
console.log('[useModelDownloadToast] Not enabled, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[useModelDownloadToast] Creating toast and EventSource for:', modelName);
|
||||||
|
|
||||||
// Create initial toast
|
// Create initial toast
|
||||||
const toastResult = toast({
|
const toastResult = toast({
|
||||||
title: displayName,
|
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
|
duration: Infinity, // Don't auto-dismiss, we'll handle it manually
|
||||||
});
|
});
|
||||||
toastIdRef.current = toastResult.id;
|
toastIdRef.current = toastResult.id;
|
||||||
toastUpdateRef.current = toastResult.update;
|
toastUpdateRef.current = toastResult.update;
|
||||||
|
|
||||||
// Subscribe to progress updates via Server-Sent Events
|
// 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) => {
|
eventSource.onmessage = (event) => {
|
||||||
|
console.log('[useModelDownloadToast] Received SSE message:', event.data);
|
||||||
try {
|
try {
|
||||||
const progress = JSON.parse(event.data) as ModelProgress;
|
const progress = JSON.parse(event.data) as ModelProgress;
|
||||||
|
|
||||||
@@ -77,19 +98,35 @@ export function useModelDownloadToast({
|
|||||||
|
|
||||||
switch (progress.status) {
|
switch (progress.status) {
|
||||||
case 'complete':
|
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';
|
statusText = 'Download complete';
|
||||||
break;
|
break;
|
||||||
case 'error':
|
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'}`;
|
statusText = `Error: ${progress.error || 'Unknown error'}`;
|
||||||
break;
|
break;
|
||||||
case 'downloading':
|
case 'downloading':
|
||||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
statusIcon = (
|
||||||
statusText = progress.filename ? `Downloading ${progress.filename}...` : 'Downloading...';
|
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||||
|
);
|
||||||
|
statusText = progress.filename || 'Downloading...';
|
||||||
break;
|
break;
|
||||||
case 'extracting':
|
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...';
|
statusText = 'Extracting...';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -117,21 +154,44 @@ export function useModelDownloadToast({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Close connection and dismiss toast on completion or error
|
// 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();
|
eventSource.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
// Auto-dismiss on completion after delay
|
// Update toast to show completion state before callbacks
|
||||||
if (progress.status === 'complete') {
|
if (isComplete && toastUpdateRef.current) {
|
||||||
setTimeout(() => {
|
|
||||||
if (toastIdRef.current && toastUpdateRef.current) {
|
|
||||||
toastUpdateRef.current({
|
toastUpdateRef.current({
|
||||||
open: false,
|
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,
|
||||||
});
|
});
|
||||||
toastIdRef.current = null;
|
|
||||||
toastUpdateRef.current = null;
|
|
||||||
}
|
}
|
||||||
}, 5000);
|
|
||||||
|
// 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) => {
|
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();
|
eventSource.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
@@ -162,13 +223,14 @@ export function useModelDownloadToast({
|
|||||||
|
|
||||||
// Cleanup on unmount or when disabled
|
// Cleanup on unmount or when disabled
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log('[useModelDownloadToast] Cleanup - closing EventSource for:', modelName);
|
||||||
if (eventSourceRef.current) {
|
if (eventSourceRef.current) {
|
||||||
eventSourceRef.current.close();
|
eventSourceRef.current.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
}
|
}
|
||||||
// Note: We don't dismiss the toast here as it might still be showing completion state
|
// 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 {
|
return {
|
||||||
isTracking: enabled && eventSourceRef.current !== null,
|
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 {
|
export interface PlatformFilesystem {
|
||||||
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
|
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 {
|
export interface UpdateStatus {
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
# Backend package
|
# Backend package
|
||||||
|
|
||||||
__version__ = "0.1.11"
|
__version__ = "0.1.13"
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ def get_tts_backend() -> TTSBackend:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
TTS backend instance (MLX or PyTorch)
|
TTS backend instance (MLX or PyTorch)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImportError: If required dependencies (mlx or torch) are not available
|
||||||
"""
|
"""
|
||||||
global _tts_backend
|
global _tts_backend
|
||||||
|
|
||||||
@@ -128,11 +131,23 @@ def get_tts_backend() -> TTSBackend:
|
|||||||
backend_type = get_backend_type()
|
backend_type = get_backend_type()
|
||||||
|
|
||||||
if backend_type == "mlx":
|
if backend_type == "mlx":
|
||||||
|
try:
|
||||||
from .mlx_backend import MLXTTSBackend
|
from .mlx_backend import MLXTTSBackend
|
||||||
_tts_backend = 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:
|
else:
|
||||||
|
try:
|
||||||
from .pytorch_backend import PyTorchTTSBackend
|
from .pytorch_backend import PyTorchTTSBackend
|
||||||
_tts_backend = 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
|
return _tts_backend
|
||||||
|
|
||||||
|
|||||||
+138
-36
@@ -52,6 +52,47 @@ class MLXTTSBackend:
|
|||||||
|
|
||||||
return hf_model_id
|
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):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the MLX TTS model.
|
Lazy load the MLX TTS model.
|
||||||
@@ -79,46 +120,63 @@ class MLXTTSBackend:
|
|||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
try:
|
try:
|
||||||
from mlx_audio.tts import load
|
# Get model path BEFORE importing mlx_audio
|
||||||
|
|
||||||
# Get model path
|
|
||||||
model_path = self._get_model_path(model_size)
|
model_path = self._get_model_path(model_size)
|
||||||
|
|
||||||
# Set up progress tracking
|
# Set up progress tracking
|
||||||
progress_manager = get_progress_manager()
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
model_name = f"qwen-tts-{model_size}"
|
model_name = f"qwen-tts-{model_size}"
|
||||||
|
|
||||||
# Start tracking download task
|
# Check if model is already cached
|
||||||
task_manager = get_task_manager()
|
is_cached = self._is_model_cached(model_size)
|
||||||
task_manager.start_download(model_name)
|
|
||||||
|
# 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}...")
|
print(f"Loading MLX TTS model {model_size}...")
|
||||||
|
|
||||||
# Initialize progress state
|
# 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(
|
progress_manager.update_progress(
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
current=0,
|
current=0,
|
||||||
total=1,
|
total=0, # Will be updated once actual total is known
|
||||||
filename="",
|
filename="Connecting to HuggingFace...",
|
||||||
status="downloading",
|
status="downloading",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up progress callback
|
# IMPORTANT: Patch tqdm BEFORE importing mlx_audio
|
||||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
# Otherwise mlx_audio caches reference to original tqdm
|
||||||
tracker = HFProgressTracker(progress_callback)
|
tracker_context = tracker.patch_download()
|
||||||
|
tracker_context.__enter__()
|
||||||
|
|
||||||
|
# Import mlx_audio AFTER patching tqdm
|
||||||
|
from mlx_audio.tts import load
|
||||||
|
|
||||||
# Use progress tracker during download
|
|
||||||
with tracker.patch_download():
|
|
||||||
# Load MLX model (downloads automatically)
|
# Load MLX model (downloads automatically)
|
||||||
|
try:
|
||||||
self.model = load(model_path)
|
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._current_model_size = model_size
|
||||||
self.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")
|
print(f"MLX TTS model {model_size} loaded successfully")
|
||||||
|
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
@@ -332,6 +390,47 @@ class MLXSTTBackend:
|
|||||||
"""Check if model is loaded."""
|
"""Check if model is loaded."""
|
||||||
return self.model is not None
|
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):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the MLX Whisper model.
|
Lazy load the MLX Whisper model.
|
||||||
@@ -354,56 +453,59 @@ class MLXSTTBackend:
|
|||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
try:
|
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()
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
progress_model_name = f"whisper-{model_size}"
|
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
|
# 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)
|
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
|
# 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 = tracker.patch_download()
|
||||||
tracker_context.__enter__()
|
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
|
from mlx_audio.stt import load
|
||||||
|
|
||||||
# MLX Whisper uses the standard OpenAI models
|
# MLX Whisper uses the standard OpenAI models
|
||||||
model_name = f"openai/whisper-{model_size}"
|
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}...")
|
print(f"Loading MLX Whisper model {model_size}...")
|
||||||
|
|
||||||
# Initialize progress state
|
# 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(
|
progress_manager.update_progress(
|
||||||
model_name=progress_model_name,
|
model_name=progress_model_name,
|
||||||
current=0,
|
current=0,
|
||||||
total=1,
|
total=0,
|
||||||
filename="",
|
filename="Connecting to HuggingFace...",
|
||||||
status="downloading",
|
status="downloading",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load the model (tqdm is already patched from above)
|
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||||
try:
|
try:
|
||||||
self.model = load(model_name)
|
self.model = load(model_name)
|
||||||
finally:
|
finally:
|
||||||
# Exit the patch context
|
# Exit the patch context
|
||||||
tracker_context.__exit__(None, None, None)
|
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:
|
||||||
# Mark as complete
|
|
||||||
progress_manager.mark_complete(progress_model_name)
|
progress_manager.mark_complete(progress_model_name)
|
||||||
task_manager.complete_download(progress_model_name)
|
task_manager.complete_download(progress_model_name)
|
||||||
|
|
||||||
|
self.model_size = model_size
|
||||||
|
|
||||||
print(f"MLX Whisper model {model_size} loaded successfully")
|
print(f"MLX Whisper model {model_size} loaded successfully")
|
||||||
|
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
|
|||||||
@@ -58,6 +58,46 @@ class PyTorchTTSBackend:
|
|||||||
|
|
||||||
return hf_model_map[model_size]
|
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):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the TTS model with automatic downloading from HuggingFace Hub.
|
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):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
try:
|
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()
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
model_name = f"qwen-tts-{model_size}"
|
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
|
# 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)
|
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
|
# Patch tqdm BEFORE importing qwen_tts
|
||||||
tracker_context = tracker.patch_download()
|
tracker_context = tracker.patch_download()
|
||||||
tracker_context.__enter__()
|
tracker_context.__enter__()
|
||||||
|
|
||||||
# NOW import qwen_tts - it will use our patched tqdm
|
# Import qwen_tts
|
||||||
from qwen_tts import Qwen3TTSModel
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
|
||||||
# Get model path (local or HuggingFace Hub ID)
|
# Get model path (local or HuggingFace Hub ID)
|
||||||
@@ -106,20 +150,21 @@ class PyTorchTTSBackend:
|
|||||||
|
|
||||||
print(f"Loading TTS model {model_size} on {self.device}...")
|
print(f"Loading TTS model {model_size} on {self.device}...")
|
||||||
|
|
||||||
|
# Only track download progress if model is NOT cached
|
||||||
|
if not is_cached:
|
||||||
# Start tracking download task
|
# Start tracking download task
|
||||||
task_manager = get_task_manager()
|
|
||||||
task_manager.start_download(model_name)
|
task_manager.start_download(model_name)
|
||||||
|
|
||||||
# Initialize progress state to show download has started
|
# Initialize progress state so SSE endpoint has initial data to send
|
||||||
progress_manager.update_progress(
|
progress_manager.update_progress(
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
current=0,
|
current=0,
|
||||||
total=1, # Set to 1 initially, will be updated by callback
|
total=0, # Will be updated once actual total is known
|
||||||
filename="",
|
filename="Connecting to HuggingFace...",
|
||||||
status="downloading",
|
status="downloading",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load the model (tqdm is already patched from above)
|
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||||
try:
|
try:
|
||||||
self.model = Qwen3TTSModel.from_pretrained(
|
self.model = Qwen3TTSModel.from_pretrained(
|
||||||
model_path,
|
model_path,
|
||||||
@@ -130,7 +175,8 @@ class PyTorchTTSBackend:
|
|||||||
# Exit the patch context
|
# Exit the patch context
|
||||||
tracker_context.__exit__(None, None, None)
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
# Mark as complete
|
# Only mark download as complete if we were tracking it
|
||||||
|
if not is_cached:
|
||||||
progress_manager.mark_complete(model_name)
|
progress_manager.mark_complete(model_name)
|
||||||
task_manager.complete_download(model_name)
|
task_manager.complete_download(model_name)
|
||||||
|
|
||||||
@@ -321,6 +367,46 @@ class PyTorchSTTBackend:
|
|||||||
"""Check if model is loaded."""
|
"""Check if model is loaded."""
|
||||||
return self.model is not None
|
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):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the Whisper model.
|
Lazy load the Whisper model.
|
||||||
@@ -349,14 +435,18 @@ class PyTorchSTTBackend:
|
|||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
print(f"[DEBUG] _load_model_sync called for Whisper {model_size}")
|
print(f"[DEBUG] _load_model_sync called for Whisper {model_size}")
|
||||||
try:
|
try:
|
||||||
# IMPORTANT: Set up progress tracking BEFORE importing transformers
|
|
||||||
# This ensures tqdm is patched before any HuggingFace Hub imports
|
|
||||||
progress_manager = get_progress_manager()
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
progress_model_name = f"whisper-{model_size}"
|
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
|
# 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)
|
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
|
# Patch tqdm BEFORE importing transformers
|
||||||
print("[DEBUG] Starting tqdm patch BEFORE transformers import")
|
print("[DEBUG] Starting tqdm patch BEFORE transformers import")
|
||||||
@@ -364,31 +454,29 @@ class PyTorchSTTBackend:
|
|||||||
tracker_context.__enter__()
|
tracker_context.__enter__()
|
||||||
print("[DEBUG] tqdm patched, now importing transformers")
|
print("[DEBUG] tqdm patched, now importing transformers")
|
||||||
|
|
||||||
# NOW import transformers - it will use our patched tqdm
|
# Import transformers
|
||||||
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||||
|
|
||||||
model_name = f"openai/whisper-{model_size}"
|
model_name = f"openai/whisper-{model_size}"
|
||||||
print(f"[DEBUG] Model name: {model_name}")
|
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}...")
|
print(f"Loading Whisper model {model_size} on {self.device}...")
|
||||||
|
|
||||||
# Initialize progress state to show download has started
|
# Only track download progress if model is NOT cached
|
||||||
print(f"[DEBUG] Calling update_progress...")
|
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(
|
progress_manager.update_progress(
|
||||||
model_name=progress_model_name,
|
model_name=progress_model_name,
|
||||||
current=0,
|
current=0,
|
||||||
total=1, # Set to 1 initially, will be updated by callback
|
total=0, # Will be updated once actual total is known
|
||||||
filename="",
|
filename="Connecting to HuggingFace...",
|
||||||
status="downloading",
|
status="downloading",
|
||||||
)
|
)
|
||||||
print(f"[DEBUG] update_progress called, listeners: {len(progress_manager._listeners.get(progress_model_name, []))}")
|
|
||||||
|
|
||||||
# Load models (tqdm is already patched from above)
|
# Load models (tqdm is patched, but filters out non-download progress)
|
||||||
try:
|
try:
|
||||||
self.processor = WhisperProcessor.from_pretrained(model_name)
|
self.processor = WhisperProcessor.from_pretrained(model_name)
|
||||||
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
||||||
@@ -396,13 +484,14 @@ class PyTorchSTTBackend:
|
|||||||
# Exit the patch context
|
# Exit the patch context
|
||||||
tracker_context.__exit__(None, None, None)
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
self.model.to(self.device)
|
# Only mark download as complete if we were tracking it
|
||||||
self.model_size = model_size
|
if not is_cached:
|
||||||
|
|
||||||
# Mark as complete
|
|
||||||
progress_manager.mark_complete(progress_model_name)
|
progress_manager.mark_complete(progress_model_name)
|
||||||
task_manager.complete_download(progress_model_name)
|
task_manager.complete_download(progress_model_name)
|
||||||
|
|
||||||
|
self.model.to(self.device)
|
||||||
|
self.model_size = model_size
|
||||||
|
|
||||||
print(f"Whisper model {model_size} loaded successfully")
|
print(f"Whisper model {model_size} loaded successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+44
-17
@@ -30,7 +30,7 @@ def build_server():
|
|||||||
args.extend(['--paths', str(qwen_tts_path)])
|
args.extend(['--paths', str(qwen_tts_path)])
|
||||||
print(f"Using local qwen_tts source from: {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([
|
args.extend([
|
||||||
'--hidden-import', 'backend',
|
'--hidden-import', 'backend',
|
||||||
'--hidden-import', 'backend.main',
|
'--hidden-import', 'backend.main',
|
||||||
@@ -42,38 +42,42 @@ def build_server():
|
|||||||
'--hidden-import', 'backend.tts',
|
'--hidden-import', 'backend.tts',
|
||||||
'--hidden-import', 'backend.transcribe',
|
'--hidden-import', 'backend.transcribe',
|
||||||
'--hidden-import', 'backend.platform_detect',
|
'--hidden-import', 'backend.platform_detect',
|
||||||
'--hidden-import', 'backend.backends',
|
'--hidden-import', 'backend.providers',
|
||||||
'--hidden-import', 'backend.backends.pytorch_backend',
|
'--hidden-import', 'backend.providers.base',
|
||||||
|
'--hidden-import', 'backend.providers.bundled',
|
||||||
|
'--hidden-import', 'backend.providers.types',
|
||||||
'--hidden-import', 'backend.utils.audio',
|
'--hidden-import', 'backend.utils.audio',
|
||||||
'--hidden-import', 'backend.utils.cache',
|
'--hidden-import', 'backend.utils.cache',
|
||||||
'--hidden-import', 'backend.utils.progress',
|
'--hidden-import', 'backend.utils.progress',
|
||||||
'--hidden-import', 'backend.utils.hf_progress',
|
'--hidden-import', 'backend.utils.hf_progress',
|
||||||
'--hidden-import', 'backend.utils.validation',
|
'--hidden-import', 'backend.utils.validation',
|
||||||
'--hidden-import', 'torch',
|
'--hidden-import', 'numpy',
|
||||||
'--hidden-import', 'transformers',
|
'--hidden-import', 'numpy.core',
|
||||||
|
'--hidden-import', 'numpy.core._multiarray_umath',
|
||||||
|
'--hidden-import', 'scipy',
|
||||||
|
'--hidden-import', 'scipy.signal',
|
||||||
'--hidden-import', 'fastapi',
|
'--hidden-import', 'fastapi',
|
||||||
'--hidden-import', 'uvicorn',
|
'--hidden-import', 'uvicorn',
|
||||||
'--hidden-import', 'sqlalchemy',
|
'--hidden-import', 'sqlalchemy',
|
||||||
'--hidden-import', 'librosa',
|
'--hidden-import', 'librosa',
|
||||||
'--hidden-import', 'soundfile',
|
'--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
|
# Fix for pkg_resources and jaraco namespace packages
|
||||||
'--hidden-import', 'pkg_resources.extern',
|
'--hidden-import', 'pkg_resources.extern',
|
||||||
'--collect-submodules', 'jaraco',
|
'--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():
|
if is_apple_silicon():
|
||||||
print("Building for Apple Silicon - including MLX dependencies")
|
print("Building for Apple Silicon - including MLX dependencies (bundled)")
|
||||||
args.extend([
|
args.extend([
|
||||||
|
'--hidden-import', 'backend.backends',
|
||||||
'--hidden-import', 'backend.backends.mlx_backend',
|
'--hidden-import', 'backend.backends.mlx_backend',
|
||||||
'--hidden-import', 'mlx',
|
'--hidden-import', 'mlx',
|
||||||
'--hidden-import', 'mlx.core',
|
'--hidden-import', 'mlx.core',
|
||||||
@@ -87,8 +91,31 @@ def build_server():
|
|||||||
'--collect-data', 'mlx',
|
'--collect-data', 'mlx',
|
||||||
'--collect-data', 'mlx_audio',
|
'--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:
|
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([
|
args.extend([
|
||||||
'--noconfirm',
|
'--noconfirm',
|
||||||
|
|||||||
+357
-55
@@ -14,7 +14,6 @@ from datetime import datetime
|
|||||||
import asyncio
|
import asyncio
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import argparse
|
import argparse
|
||||||
import torch
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import io
|
import io
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -23,12 +22,22 @@ import asyncio
|
|||||||
import signal
|
import signal
|
||||||
import os
|
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 . 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 .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||||
from .utils.progress import get_progress_manager
|
from .utils.progress import get_progress_manager
|
||||||
from .utils.tasks import get_task_manager
|
from .utils.tasks import get_task_manager
|
||||||
from .utils.cache import clear_voice_prompt_cache
|
from .utils.cache import clear_voice_prompt_cache
|
||||||
from .platform_detect import get_backend_type
|
from .platform_detect import get_backend_type
|
||||||
|
from .providers import get_provider_manager
|
||||||
|
from .providers.types import ProviderType
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="voicebox API",
|
title="voicebox API",
|
||||||
@@ -50,10 +59,8 @@ app.add_middleware(
|
|||||||
# ROOT & HEALTH ENDPOINTS
|
# ROOT & HEALTH ENDPOINTS
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@app.get("/")
|
# Root endpoint removed - web UI served at / instead
|
||||||
async def root():
|
# API info available at /health
|
||||||
"""Root endpoint."""
|
|
||||||
return {"message": "voicebox API", "version": __version__}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/shutdown")
|
@app.post("/shutdown")
|
||||||
@@ -67,23 +74,48 @@ async def shutdown():
|
|||||||
return {"message": "Shutting down..."}
|
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)
|
@app.get("/health", response_model=models.HealthResponse)
|
||||||
async def health():
|
async def health():
|
||||||
"""Health check endpoint."""
|
"""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
|
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()
|
backend_type = get_backend_type()
|
||||||
|
|
||||||
# Check for GPU availability (CUDA or MPS)
|
# Check for GPU availability (CUDA or MPS)
|
||||||
|
# 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_cuda = torch.cuda.is_available()
|
||||||
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
||||||
|
|
||||||
gpu_available = has_cuda or has_mps
|
gpu_available = has_cuda or has_mps
|
||||||
|
|
||||||
gpu_type = None
|
gpu_type = None
|
||||||
if has_cuda:
|
if has_cuda and torch is not None:
|
||||||
gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})"
|
gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})"
|
||||||
elif has_mps:
|
elif has_mps:
|
||||||
gpu_type = "MPS (Apple Silicon)"
|
gpu_type = "MPS (Apple Silicon)"
|
||||||
@@ -91,12 +123,13 @@ async def health():
|
|||||||
gpu_type = "Metal (Apple Silicon via MLX)"
|
gpu_type = "Metal (Apple Silicon via MLX)"
|
||||||
|
|
||||||
vram_used = None
|
vram_used = None
|
||||||
if has_cuda:
|
if has_cuda and torch is not None:
|
||||||
vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB
|
vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB
|
||||||
|
|
||||||
# Check if model is loaded - use the same logic as model status endpoint
|
# Check if model is loaded - use the same logic as model status endpoint
|
||||||
model_loaded = False
|
model_loaded = False
|
||||||
model_size = None
|
model_size = None
|
||||||
|
if tts_model is not None:
|
||||||
try:
|
try:
|
||||||
# Use the same check as model status endpoint
|
# Use the same check as model status endpoint
|
||||||
if tts_model.is_loaded():
|
if tts_model.is_loaded():
|
||||||
@@ -549,7 +582,7 @@ async def generate_speech(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Generate audio
|
# 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)
|
# Load the requested model size if different from current (async to not block)
|
||||||
model_size = data.model_size or "1.7B"
|
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"):
|
async def load_model(model_size: str = "1.7B"):
|
||||||
"""Manually load TTS model."""
|
"""Manually load TTS model."""
|
||||||
try:
|
try:
|
||||||
tts_model = tts.get_tts_model()
|
tts_model = await tts.get_tts_model_async()
|
||||||
await tts_model.load_model_async(model_size)
|
await tts_model.load_model(model_size)
|
||||||
return {"message": f"Model {model_size} loaded successfully"}
|
return {"message": f"Model {model_size} loaded successfully"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(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)
|
@app.get("/models/status", response_model=models.ModelStatusListResponse)
|
||||||
async def get_model_status():
|
async def get_model_status():
|
||||||
"""Get status of all available models."""
|
"""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
|
from pathlib import Path
|
||||||
import os
|
|
||||||
|
|
||||||
backend_type = get_backend_type()
|
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 to import scan_cache_dir (might not be available in older versions)
|
||||||
try:
|
try:
|
||||||
@@ -1169,10 +1205,10 @@ async def get_model_status():
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
use_scan_cache = False
|
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."""
|
"""Check if TTS model is loaded with specific size."""
|
||||||
try:
|
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
|
return tts_model.is_loaded() and getattr(tts_model, 'model_size', None) == model_size
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
@@ -1189,10 +1225,11 @@ async def get_model_status():
|
|||||||
if backend_type == "mlx":
|
if backend_type == "mlx":
|
||||||
tts_1_7b_id = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
|
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
|
tts_0_6b_id = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16" # Fallback to 1.7B
|
||||||
whisper_base_id = "mlx-community/whisper-base"
|
# MLX backend uses openai/whisper-* models, not mlx-community
|
||||||
whisper_small_id = "mlx-community/whisper-small"
|
whisper_base_id = "openai/whisper-base"
|
||||||
whisper_medium_id = "mlx-community/whisper-medium"
|
whisper_small_id = "openai/whisper-small"
|
||||||
whisper_large_id = "mlx-community/whisper-large"
|
whisper_medium_id = "openai/whisper-medium"
|
||||||
|
whisper_large_id = "openai/whisper-large"
|
||||||
else:
|
else:
|
||||||
tts_1_7b_id = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
tts_1_7b_id = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||||
tts_0_6b_id = "Qwen/Qwen3-TTS-12Hz-0.6B-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",
|
"display_name": "Qwen TTS 1.7B",
|
||||||
"hf_repo_id": tts_1_7b_id,
|
"hf_repo_id": tts_1_7b_id,
|
||||||
"model_size": "1.7B",
|
"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",
|
"model_name": "qwen-tts-0.6B",
|
||||||
"display_name": "Qwen TTS 0.6B",
|
"display_name": "Qwen TTS 0.6B",
|
||||||
"hf_repo_id": tts_0_6b_id,
|
"hf_repo_id": tts_0_6b_id,
|
||||||
"model_size": "0.6B",
|
"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",
|
"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)
|
# Get HuggingFace cache info (if available)
|
||||||
cache_info = None
|
cache_info = None
|
||||||
if use_scan_cache:
|
if use_scan_cache:
|
||||||
@@ -1268,6 +1312,30 @@ async def get_model_status():
|
|||||||
repo_id = config["hf_repo_id"]
|
repo_id = config["hf_repo_id"]
|
||||||
for repo in cache_info.repos:
|
for repo in cache_info.repos:
|
||||||
if repo.repo_id == repo_id:
|
if repo.repo_id == repo_id:
|
||||||
|
# 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:
|
||||||
|
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
|
downloaded = True
|
||||||
# Calculate size from cache info
|
# Calculate size from cache info
|
||||||
try:
|
try:
|
||||||
@@ -1284,67 +1352,96 @@ async def get_model_status():
|
|||||||
repo_cache = Path(cache_dir) / ("models--" + config["hf_repo_id"].replace("/", "--"))
|
repo_cache = Path(cache_dir) / ("models--" + config["hf_repo_id"].replace("/", "--"))
|
||||||
|
|
||||||
if repo_cache.exists():
|
if repo_cache.exists():
|
||||||
# Check for model files (bin, safetensors, or other common model files)
|
# Check for .incomplete files - if any exist, download is still in progress
|
||||||
# MLX models may use .npz or .safetensors
|
blobs_dir = repo_cache / "blobs"
|
||||||
|
has_incomplete = blobs_dir.exists() and any(blobs_dir.glob("*.incomplete"))
|
||||||
|
|
||||||
|
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 = (
|
has_model_files = (
|
||||||
any(repo_cache.rglob("*.bin")) or
|
any(snapshots_dir.rglob("*.bin")) or
|
||||||
any(repo_cache.rglob("*.safetensors")) or
|
any(snapshots_dir.rglob("*.safetensors")) or
|
||||||
any(repo_cache.rglob("*.pt")) or
|
any(snapshots_dir.rglob("*.pt")) or
|
||||||
any(repo_cache.rglob("*.pth")) or
|
any(snapshots_dir.rglob("*.pth")) or
|
||||||
any(repo_cache.rglob("*.npz")) or
|
any(snapshots_dir.rglob("*.npz"))
|
||||||
any(repo_cache.rglob("model.safetensors.index.json")) or
|
|
||||||
any(repo_cache.rglob("pytorch_model.bin.index.json"))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if has_model_files:
|
if has_model_files:
|
||||||
downloaded = True
|
downloaded = True
|
||||||
# Calculate size
|
# Calculate size (exclude .incomplete files)
|
||||||
try:
|
try:
|
||||||
total_size = sum(f.stat().st_size for f in repo_cache.rglob("*") if f.is_file())
|
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)
|
size_mb = total_size / (1024 * 1024)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Method 3: Try to check if model can be loaded locally (last resort)
|
# Method 3 removed - checking for config.json is too lenient
|
||||||
if not downloaded:
|
# Methods 1 and 2 properly verify that model weight files exist
|
||||||
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
|
|
||||||
|
|
||||||
# Check if loaded in memory
|
# Check if loaded in memory
|
||||||
try:
|
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:
|
except Exception:
|
||||||
loaded = False
|
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(
|
statuses.append(models.ModelStatus(
|
||||||
model_name=config["model_name"],
|
model_name=config["model_name"],
|
||||||
display_name=config["display_name"],
|
display_name=config["display_name"],
|
||||||
downloaded=downloaded,
|
downloaded=downloaded,
|
||||||
|
downloading=is_downloading,
|
||||||
size_mb=size_mb,
|
size_mb=size_mb,
|
||||||
loaded=loaded,
|
loaded=loaded,
|
||||||
))
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If check fails, try to at least check if loaded
|
# If check fails, try to at least check if loaded
|
||||||
try:
|
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:
|
except Exception:
|
||||||
loaded = False
|
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(
|
statuses.append(models.ModelStatus(
|
||||||
model_name=config["model_name"],
|
model_name=config["model_name"],
|
||||||
display_name=config["display_name"],
|
display_name=config["display_name"],
|
||||||
downloaded=False, # Assume not downloaded if check failed
|
downloaded=False, # Assume not downloaded if check failed
|
||||||
|
downloading=is_downloading,
|
||||||
size_mb=None,
|
size_mb=None,
|
||||||
loaded=loaded,
|
loaded=loaded,
|
||||||
))
|
))
|
||||||
@@ -1358,15 +1455,26 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
task_manager = get_task_manager()
|
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 = {
|
model_configs = {
|
||||||
"qwen-tts-1.7B": {
|
"qwen-tts-1.7B": {
|
||||||
"model_size": "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": {
|
"qwen-tts-0.6B": {
|
||||||
"model_size": "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": {
|
"whisper-base": {
|
||||||
"model_size": "base",
|
"model_size": "base",
|
||||||
@@ -1406,6 +1514,18 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
|||||||
# Start tracking download
|
# Start tracking download
|
||||||
task_manager.start_download(request.model_name)
|
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)
|
# Start download in background task (don't await)
|
||||||
asyncio.create_task(download_in_background())
|
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"}
|
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}")
|
@app.delete("/models/{model_name}")
|
||||||
async def delete_model(model_name: str):
|
async def delete_model(model_name: str):
|
||||||
"""Delete a downloaded model from the HuggingFace cache."""
|
"""Delete a downloaded model from the HuggingFace cache."""
|
||||||
@@ -1463,9 +1748,9 @@ async def delete_model(model_name: str):
|
|||||||
try:
|
try:
|
||||||
# Check if model is loaded and unload it first
|
# Check if model is loaded and unload it first
|
||||||
if config["model_type"] == "tts":
|
if config["model_type"] == "tts":
|
||||||
tts_model = tts.get_tts_model()
|
tts_model = await tts.get_tts_model_async()
|
||||||
if tts_model.is_loaded() and tts_model.model_size == config["model_size"]:
|
if tts_model.is_loaded() and getattr(tts_model, 'model_size', None) == config["model_size"]:
|
||||||
tts.unload_tts_model()
|
tts_model.unload_model()
|
||||||
elif config["model_type"] == "whisper":
|
elif config["model_type"] == "whisper":
|
||||||
whisper_model = transcribe.get_whisper_model()
|
whisper_model = transcribe.get_whisper_model()
|
||||||
if whisper_model.is_loaded() and whisper_model.model_size == config["model_size"]:
|
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
|
# STARTUP & SHUTDOWN
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -1582,11 +1877,12 @@ async def get_active_tasks():
|
|||||||
def _get_gpu_status() -> str:
|
def _get_gpu_status() -> str:
|
||||||
"""Get GPU availability status."""
|
"""Get GPU availability status."""
|
||||||
backend_type = get_backend_type()
|
backend_type = get_backend_type()
|
||||||
|
if TORCH_AVAILABLE and torch is not None:
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
return f"CUDA ({torch.cuda.get_device_name(0)})"
|
return f"CUDA ({torch.cuda.get_device_name(0)})"
|
||||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||||
return "MPS (Apple Silicon)"
|
return "MPS (Apple Silicon)"
|
||||||
elif backend_type == "mlx":
|
if backend_type == "mlx":
|
||||||
return "Metal (Apple Silicon via MLX)"
|
return "Metal (Apple Silicon via MLX)"
|
||||||
return "None (CPU only)"
|
return "None (CPU only)"
|
||||||
|
|
||||||
@@ -1625,8 +1921,14 @@ async def shutdown_event():
|
|||||||
"""Run on application shutdown."""
|
"""Run on application shutdown."""
|
||||||
print("voicebox API shutting down...")
|
print("voicebox API shutting down...")
|
||||||
# Unload models to free memory
|
# Unload models to free memory
|
||||||
|
try:
|
||||||
tts.unload_tts_model()
|
tts.unload_tts_model()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to unload TTS model: {e}")
|
||||||
|
try:
|
||||||
transcribe.unload_whisper_model()
|
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
|
model_name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
downloaded: bool
|
downloaded: bool
|
||||||
|
downloading: bool = False # True if download is in progress
|
||||||
size_mb: Optional[float] = None
|
size_mb: Optional[float] = None
|
||||||
loaded: bool = False
|
loaded: bool = False
|
||||||
|
|
||||||
@@ -169,6 +170,13 @@ class ActiveTasksResponse(BaseModel):
|
|||||||
generations: List[ActiveGenerationTask]
|
generations: List[ActiveGenerationTask]
|
||||||
|
|
||||||
|
|
||||||
|
class FolderPathsResponse(BaseModel):
|
||||||
|
"""Response model for system folder paths."""
|
||||||
|
data_dir: str
|
||||||
|
models_dir: str
|
||||||
|
providers_dir: str
|
||||||
|
|
||||||
|
|
||||||
class AudioChannelCreate(BaseModel):
|
class AudioChannelCreate(BaseModel):
|
||||||
"""Request model for creating an audio channel."""
|
"""Request model for creating an audio channel."""
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
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
|
from typing import Optional
|
||||||
@@ -7,31 +7,51 @@ import numpy as np
|
|||||||
import io
|
import io
|
||||||
import soundfile as sf
|
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:
|
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():
|
def unload_tts_model():
|
||||||
"""Unload TTS model to free memory."""
|
"""Unload TTS model to free memory."""
|
||||||
backend = get_tts_backend()
|
manager = get_provider_manager()
|
||||||
backend.unload_model()
|
provider = manager._get_default_provider()
|
||||||
|
provider.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()
|
|
||||||
|
|
||||||
|
|
||||||
def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
|
def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
|
||||||
|
|||||||
+145
-23
@@ -11,8 +11,9 @@ import sys
|
|||||||
class HFProgressTracker:
|
class HFProgressTracker:
|
||||||
"""Tracks HuggingFace Hub download progress by intercepting tqdm."""
|
"""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.progress_callback = progress_callback
|
||||||
|
self.filter_non_downloads = filter_non_downloads # Only filter if True
|
||||||
self._original_tqdm_class = None
|
self._original_tqdm_class = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._total_downloaded = 0
|
self._total_downloaded = 0
|
||||||
@@ -21,6 +22,7 @@ class HFProgressTracker:
|
|||||||
self._file_downloaded = {} # Track downloaded bytes per file
|
self._file_downloaded = {} # Track downloaded bytes per file
|
||||||
self._current_filename = ""
|
self._current_filename = ""
|
||||||
self._active_tqdms = {} # Track active tqdm instances
|
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):
|
def _create_tracked_tqdm_class(self):
|
||||||
"""Create a tqdm subclass that tracks progress."""
|
"""Create a tqdm subclass that tracks progress."""
|
||||||
@@ -31,7 +33,6 @@ class HFProgressTracker:
|
|||||||
"""A tqdm subclass that reports progress to our tracker."""
|
"""A tqdm subclass that reports progress to our tracker."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
print(f"[DEBUG TrackedTqdm] __init__ called with desc: {kwargs.get('desc', '')}")
|
|
||||||
# Extract filename from desc before passing to parent
|
# Extract filename from desc before passing to parent
|
||||||
desc = kwargs.get("desc", "")
|
desc = kwargs.get("desc", "")
|
||||||
if not desc and args:
|
if not desc and args:
|
||||||
@@ -80,7 +81,6 @@ class HFProgressTracker:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def update(self, n=1):
|
def update(self, n=1):
|
||||||
print(f"[DEBUG TrackedTqdm] update called with n={n}")
|
|
||||||
result = super().update(n)
|
result = super().update(n)
|
||||||
|
|
||||||
# Report progress
|
# Report progress
|
||||||
@@ -91,6 +91,16 @@ class HFProgressTracker:
|
|||||||
total = getattr(self, "total", 0)
|
total = getattr(self, "total", 0)
|
||||||
|
|
||||||
if total and 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
|
# Update per-file tracking
|
||||||
tracker._file_sizes[filename] = total
|
tracker._file_sizes[filename] = total
|
||||||
tracker._file_downloaded[filename] = current
|
tracker._file_downloaded[filename] = current
|
||||||
@@ -99,6 +109,13 @@ class HFProgressTracker:
|
|||||||
tracker._total_size = sum(tracker._file_sizes.values())
|
tracker._total_size = sum(tracker._file_sizes.values())
|
||||||
tracker._total_downloaded = sum(tracker._file_downloaded.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
|
# Call progress callback
|
||||||
if tracker.progress_callback:
|
if tracker.progress_callback:
|
||||||
tracker.progress_callback(
|
tracker.progress_callback(
|
||||||
@@ -109,6 +126,50 @@ class HFProgressTracker:
|
|||||||
|
|
||||||
return result
|
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):
|
def close(self):
|
||||||
with tracker._lock:
|
with tracker._lock:
|
||||||
if id(self) in tracker._active_tqdms:
|
if id(self) in tracker._active_tqdms:
|
||||||
@@ -120,13 +181,11 @@ class HFProgressTracker:
|
|||||||
@contextmanager
|
@contextmanager
|
||||||
def patch_download(self):
|
def patch_download(self):
|
||||||
"""Context manager to patch tqdm for progress tracking."""
|
"""Context manager to patch tqdm for progress tracking."""
|
||||||
print("[DEBUG HFProgressTracker] patch_download called")
|
|
||||||
try:
|
try:
|
||||||
import tqdm as tqdm_module
|
import tqdm as tqdm_module
|
||||||
|
|
||||||
# Store original tqdm class
|
# Store original tqdm class
|
||||||
self._original_tqdm_class = tqdm_module.tqdm
|
self._original_tqdm_class = tqdm_module.tqdm
|
||||||
print(f"[DEBUG HFProgressTracker] Original tqdm class: {self._original_tqdm_class}")
|
|
||||||
|
|
||||||
# Reset totals
|
# Reset totals
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -139,39 +198,89 @@ class HFProgressTracker:
|
|||||||
|
|
||||||
# Create our tracked tqdm class
|
# Create our tracked tqdm class
|
||||||
tracked_tqdm = self._create_tracked_tqdm_class()
|
tracked_tqdm = self._create_tracked_tqdm_class()
|
||||||
print(f"[DEBUG HFProgressTracker] Created TrackedTqdm class: {tracked_tqdm}")
|
|
||||||
|
|
||||||
# Patch tqdm.tqdm
|
# Patch tqdm.tqdm
|
||||||
tqdm_module.tqdm = tracked_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)
|
# Also patch tqdm.auto.tqdm if it exists (used by huggingface_hub)
|
||||||
self._original_tqdm_auto = None
|
self._original_tqdm_auto = None
|
||||||
if hasattr(tqdm_module, "auto") and hasattr(tqdm_module.auto, "tqdm"):
|
if hasattr(tqdm_module, "auto") and hasattr(tqdm_module.auto, "tqdm"):
|
||||||
self._original_tqdm_auto = tqdm_module.auto.tqdm
|
self._original_tqdm_auto = tqdm_module.auto.tqdm
|
||||||
tqdm_module.auto.tqdm = tracked_tqdm
|
tqdm_module.auto.tqdm = tracked_tqdm
|
||||||
print(f"[DEBUG HFProgressTracker] Patched tqdm.auto.tqdm")
|
|
||||||
|
|
||||||
# Patch in sys.modules to catch already-imported references
|
# 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 = {}
|
self._patched_modules = {}
|
||||||
|
tqdm_attr_names = ['tqdm', 'base_tqdm', 'old_tqdm'] # Various names used
|
||||||
|
|
||||||
patched_count = 0
|
patched_count = 0
|
||||||
for module_name in list(sys.modules.keys()):
|
for module_name in list(sys.modules.keys()):
|
||||||
if "huggingface" in module_name or module_name.startswith("tqdm"):
|
if "huggingface" in module_name or module_name.startswith("tqdm"):
|
||||||
try:
|
try:
|
||||||
module = sys.modules[module_name]
|
module = sys.modules[module_name]
|
||||||
if hasattr(module, "tqdm"):
|
for attr_name in tqdm_attr_names:
|
||||||
attr = getattr(module, "tqdm")
|
if hasattr(module, attr_name):
|
||||||
# Only patch if it's the original tqdm class (not already patched)
|
attr = getattr(module, attr_name)
|
||||||
if attr is self._original_tqdm_class or (
|
# Only patch if it's a tqdm class (not already patched)
|
||||||
hasattr(attr, "__name__") and attr.__name__ == "tqdm"
|
is_tqdm_class = (
|
||||||
):
|
attr is self._original_tqdm_class or
|
||||||
self._patched_modules[module_name] = attr
|
(self._original_tqdm_auto and attr is self._original_tqdm_auto) or
|
||||||
setattr(module, "tqdm", tracked_tqdm)
|
(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
|
patched_count += 1
|
||||||
print(f"[DEBUG HFProgressTracker] Patched {module_name}.tqdm")
|
|
||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
pass
|
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
|
yield
|
||||||
|
|
||||||
@@ -189,15 +298,24 @@ class HFProgressTracker:
|
|||||||
tqdm_module.auto.tqdm = self._original_tqdm_auto
|
tqdm_module.auto.tqdm = self._original_tqdm_auto
|
||||||
|
|
||||||
# Restore patched modules
|
# Restore patched modules
|
||||||
for module_name, original in self._patched_modules.items():
|
for key, (module, attr_name, original) in self._patched_modules.items():
|
||||||
try:
|
try:
|
||||||
module = sys.modules.get(module_name)
|
|
||||||
if module and original:
|
if module and original:
|
||||||
setattr(module, "tqdm", original)
|
setattr(module, attr_name, original)
|
||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
pass
|
pass
|
||||||
self._patched_modules = {}
|
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):
|
except (ImportError, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -205,8 +323,12 @@ class HFProgressTracker:
|
|||||||
def create_hf_progress_callback(model_name: str, progress_manager):
|
def create_hf_progress_callback(model_name: str, progress_manager):
|
||||||
"""Create a progress callback for HuggingFace downloads."""
|
"""Create a progress callback for HuggingFace downloads."""
|
||||||
def callback(downloaded: int, total: int, filename: str = ""):
|
def callback(downloaded: int, total: int, filename: str = ""):
|
||||||
"""Progress callback."""
|
"""Progress callback.
|
||||||
if total > 0:
|
|
||||||
|
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(
|
progress_manager.update_progress(
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
current=downloaded,
|
current=downloaded,
|
||||||
|
|||||||
+52
-15
@@ -16,11 +16,17 @@ class ProgressManager:
|
|||||||
Thread-safe: can be called from background threads (e.g., via asyncio.to_thread).
|
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):
|
def __init__(self):
|
||||||
self._progress: Dict[str, Dict] = {}
|
self._progress: Dict[str, Dict] = {}
|
||||||
self._listeners: Dict[str, list] = {}
|
self._listeners: Dict[str, list] = {}
|
||||||
self._lock = threading.Lock() # Thread-safe lock for progress dict
|
self._lock = threading.Lock() # Thread-safe lock for progress dict
|
||||||
self._main_loop: Optional[asyncio.AbstractEventLoop] = None
|
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):
|
def _set_main_loop(self, loop: asyncio.AbstractEventLoop):
|
||||||
"""Set the main event loop for thread-safe operations."""
|
"""Set the main event loop for thread-safe operations."""
|
||||||
@@ -43,11 +49,17 @@ class ProgressManager:
|
|||||||
queue.put_nowait(progress_data.copy())
|
queue.put_nowait(progress_data.copy())
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# Not in async context (running in background thread)
|
# 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():
|
if self._main_loop and self._main_loop.is_running():
|
||||||
self._main_loop.call_soon_threadsafe(
|
async def put_data_async():
|
||||||
lambda q=queue, d=progress_data.copy(): q.put_nowait(d) if not q.full() else None
|
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:
|
else:
|
||||||
logger.debug(f"No main loop available for {model_name}, skipping notification")
|
logger.debug(f"No main loop available for {model_name}, skipping notification")
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
@@ -68,6 +80,10 @@ class ProgressManager:
|
|||||||
|
|
||||||
Thread-safe: can be called from background threads.
|
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:
|
Args:
|
||||||
model_name: Name of the model (e.g., "qwen-tts-1.7B", "whisper-base")
|
model_name: Name of the model (e.g., "qwen-tts-1.7B", "whisper-base")
|
||||||
current: Current bytes downloaded
|
current: Current bytes downloaded
|
||||||
@@ -76,9 +92,17 @@ class ProgressManager:
|
|||||||
status: Status string (downloading, extracting, complete, error)
|
status: Status string (downloading, extracting, complete, error)
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
logger = logging.getLogger(__name__)
|
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 = {
|
progress_data = {
|
||||||
"model_name": model_name,
|
"model_name": model_name,
|
||||||
@@ -90,25 +114,38 @@ class ProgressManager:
|
|||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
print(f"[DEBUG] update_progress called: {model_name}, {progress_pct:.1f}%")
|
# Thread-safe update of progress dict (always update internal state)
|
||||||
|
|
||||||
# Thread-safe update of progress dict
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._progress[model_name] = progress_data
|
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)
|
# Notify all listeners (thread-safe)
|
||||||
listener_count = len(self._listeners.get(model_name, []))
|
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:
|
if listener_count > 0:
|
||||||
logger.debug(f"Notifying {listener_count} listeners for {model_name}: {progress_pct:.1f}% ({filename})")
|
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)
|
self._notify_listeners_threadsafe(model_name, progress_data)
|
||||||
print(f"[DEBUG] Notified listeners")
|
|
||||||
else:
|
else:
|
||||||
logger.debug(f"No listeners for {model_name}, progress update stored: {progress_pct:.1f}%")
|
logger.debug(f"No listeners for {model_name}, progress update stored: {progress_pct:.1f}%")
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
from PyInstaller.utils.hooks import collect_data_files
|
from PyInstaller.utils.hooks import collect_data_files
|
||||||
from PyInstaller.utils.hooks import collect_submodules
|
from PyInstaller.utils.hooks import collect_submodules
|
||||||
from PyInstaller.utils.hooks import copy_metadata
|
|
||||||
|
|
||||||
datas = []
|
datas = []
|
||||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.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('qwen_tts')
|
|
||||||
datas += collect_data_files('mlx')
|
datas += collect_data_files('mlx')
|
||||||
datas += collect_data_files('mlx_audio')
|
datas += collect_data_files('mlx_audio')
|
||||||
datas += copy_metadata('qwen-tts')
|
|
||||||
hiddenimports += collect_submodules('qwen_tts')
|
|
||||||
hiddenimports += collect_submodules('jaraco')
|
hiddenimports += collect_submodules('jaraco')
|
||||||
hiddenimports += collect_submodules('mlx')
|
hiddenimports += collect_submodules('mlx')
|
||||||
hiddenimports += collect_submodules('mlx_audio')
|
hiddenimports += collect_submodules('mlx_audio')
|
||||||
|
|||||||
@@ -13,12 +13,16 @@
|
|||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.9",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@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-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
@@ -26,6 +30,7 @@
|
|||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-popover": "^1.1.1",
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
"@radix-ui/react-progress": "^1.1.0",
|
"@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-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.1",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
@@ -45,7 +50,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"framer-motion": "^12.29.0",
|
"framer-motion": "^12.29.0",
|
||||||
"lucide-react": "^0.454.0",
|
|
||||||
"motion": "^12.29.0",
|
"motion": "^12.29.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
@@ -68,7 +72,7 @@
|
|||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"name": "@voicebox/landing",
|
"name": "@voicebox/landing",
|
||||||
"version": "0.1.9",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
@@ -93,10 +97,14 @@
|
|||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"name": "@voicebox/tauri",
|
"name": "@voicebox/tauri",
|
||||||
"version": "0.1.9",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@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-shell": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
@@ -112,7 +120,7 @@
|
|||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"name": "@voicebox/web",
|
"name": "@voicebox/web",
|
||||||
"version": "0.1.9",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
@@ -121,6 +129,7 @@
|
|||||||
"zustand": "^4.5.0",
|
"zustand": "^4.5.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
@@ -129,6 +138,7 @@
|
|||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.0",
|
"eslint-plugin-react-refresh": "^0.4.0",
|
||||||
|
"tailwindcss": "^4.1.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vite": "^5.4.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=="],
|
"@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/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/module-importer": ["@humanwhocodes/[email protected]", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||||
|
|
||||||
"@humanwhocodes/object-schema": ["@humanwhocodes/[email protected]", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
|
"@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/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=="],
|
"@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-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-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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"@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/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=="],
|
"@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",
|
"group": "Getting Started",
|
||||||
"icon": "rocket",
|
"icon": "rocket",
|
||||||
"pages": ["overview/introduction", "overview/installation", "overview/quick-start"]
|
"pages": ["overview/introduction", "overview/installation", "overview/docker", "overview/quick-start"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "Features",
|
"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
|
## 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">
|
<Card title="macOS" icon="apple">
|
||||||
Download for Apple Silicon or Intel Macs
|
Download for Apple Silicon or Intel Macs
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Windows" icon="windows">
|
<Card title="Windows" icon="windows">
|
||||||
Download MSI installer or Setup executable
|
Download MSI installer or Setup executable
|
||||||
</Card>
|
</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>
|
</CardGroup>
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
@@ -60,8 +66,33 @@ Voicebox is available for macOS and Windows, with Linux builds coming soon.
|
|||||||
|
|
||||||
### Linux
|
### 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>
|
<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>
|
</Note>
|
||||||
|
|
||||||
## First Launch
|
## First Launch
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: "Welcome to Voicebox - the open-source voice synthesis studio"
|
|||||||
|
|
||||||
## What is Voicebox?
|
## 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>
|
<Frame>
|
||||||
<img src="/images/app-screenshot-1.webp" alt="Voicebox App Screenshot" />
|
<img src="/images/app-screenshot-1.webp" alt="Voicebox App Screenshot" />
|
||||||
|
|||||||
+72
-193
@@ -1,24 +1,31 @@
|
|||||||
# Docker Deployment Guide
|
# Docker Deployment Guide
|
||||||
|
|
||||||
**Status:** In Development for v0.2.0
|
**Status:** Implemented
|
||||||
**Requested By:** Reddit community ([thread](https://reddit.com/r/LocalLLaMA/...))
|
**Images:** `ghcr.io/jamiepine/voicebox`
|
||||||
|
|
||||||
## Overview
|
## 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
|
**What's included:**
|
||||||
- **GPU Passthrough**: Easy NVIDIA/AMD GPU access
|
- 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
|
- **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
|
- **Cloud Platforms**: Deploy to AWS, GCP, Azure, DigitalOcean
|
||||||
|
- **Multi-User Setups**: Isolate instances per user/team
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Using Pre-Built Images (Recommended)
|
### Using Pre-Built Images (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# CPU-only version
|
# CPU-only version (supports amd64 and arm64)
|
||||||
docker run -p 8000:8000 -v voicebox-data:/app/data \
|
docker run -p 8000:8000 -v voicebox-data:/app/data \
|
||||||
ghcr.io/jamiepine/voicebox:latest
|
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 \
|
docker run --gpus all -p 8000:8000 -v voicebox-data:/app/data \
|
||||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
|
|
||||||
# AMD GPU version (experimental)
|
# Specific version (pinned for stability)
|
||||||
docker run --device=/dev/kfd --device=/dev/dri -p 8000:8000 \
|
docker run -p 8000:8000 -v voicebox-data:/app/data \
|
||||||
-v voicebox-data:/app/data \
|
ghcr.io/jamiepine/voicebox:0.1.13
|
||||||
ghcr.io/jamiepine/voicebox:latest-rocm
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open: `http://localhost:8000`
|
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)
|
### 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
|
```bash
|
||||||
version: '3.8'
|
# CUDA (default)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
services:
|
# Or CPU-only
|
||||||
voicebox:
|
docker compose -f docker-compose.cpu.yml up -d
|
||||||
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:
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run:
|
To pin to a specific version, edit the compose file:
|
||||||
```bash
|
```yaml
|
||||||
docker compose up -d
|
services:
|
||||||
|
voicebox:
|
||||||
|
image: ghcr.io/jamiepine/voicebox:0.1.13-cuda # Pinned version
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building From Source
|
## Building From Source
|
||||||
|
|
||||||
### Basic Dockerfile
|
See `Dockerfile` and `Dockerfile.cuda` in the repository root.
|
||||||
|
|
||||||
```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"]
|
|
||||||
```
|
|
||||||
|
|
||||||
Build and run:
|
Build and run:
|
||||||
```bash
|
```bash
|
||||||
|
# Build web UI first
|
||||||
|
bun install
|
||||||
|
cd web && bun run build && cd ..
|
||||||
|
|
||||||
|
# Build CPU image
|
||||||
docker build -t voicebox .
|
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
|
Images are automatically built on release and tagged with both version number and `latest`.
|
||||||
# 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 .
|
|
||||||
```
|
|
||||||
|
|
||||||
## GPU Support
|
## GPU Support
|
||||||
|
|
||||||
### NVIDIA GPUs (CUDA)
|
### NVIDIA GPUs (CUDA)
|
||||||
|
|
||||||
**Dockerfile:**
|
The CUDA image includes PyTorch with CUDA 12.1 support:
|
||||||
```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"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Run with GPU:**
|
**Run with GPU:**
|
||||||
```bash
|
```bash
|
||||||
docker run --gpus all -p 8000:8000 \
|
docker run --gpus all -p 8000:8000 \
|
||||||
-v voicebox-data:/app/data \
|
-v voicebox-data:/app/data \
|
||||||
voicebox:cuda
|
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker Compose with GPU:**
|
**Docker Compose with GPU:**
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
voicebox:
|
voicebox:
|
||||||
image: voicebox:cuda
|
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
reservations:
|
reservations:
|
||||||
@@ -213,47 +116,9 @@ services:
|
|||||||
capabilities: [gpu]
|
capabilities: [gpu]
|
||||||
```
|
```
|
||||||
|
|
||||||
### AMD GPUs (ROCm) - Experimental
|
### AMD GPUs (ROCm)
|
||||||
|
|
||||||
**Dockerfile:**
|
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.
|
||||||
```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.
|
|
||||||
|
|
||||||
## Volume Mounts
|
## Volume Mounts
|
||||||
|
|
||||||
@@ -734,13 +599,27 @@ docker logs -f voicebox
|
|||||||
docker compose logs -f voicebox
|
docker compose logs -f voicebox
|
||||||
```
|
```
|
||||||
|
|
||||||
## Next Steps
|
## Updates
|
||||||
|
|
||||||
- [ ] Publish official images to GitHub Container Registry
|
Docker images are automatically built and published on each GitHub release. To update:
|
||||||
- [ ] Add Kubernetes Helm charts
|
|
||||||
- [ ] Create Docker Desktop extension
|
```bash
|
||||||
- [ ] Add automated vulnerability scanning
|
# Pull latest
|
||||||
- [ ] Support ARM64 builds for Raspberry Pi / Apple Silicon
|
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
|
## 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