mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-27 22:25:15 -07:00
Compare commits
73
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 | ||
|
|
e5f4606a6c | ||
|
|
146ef5aaeb | ||
|
|
971604d14f | ||
|
|
0b17073345 | ||
|
|
17106b1e40 | ||
|
|
953e6ec7d8 | ||
|
|
d3c65fc6c2 | ||
|
|
7fcca09f24 | ||
|
|
b6e772c6ac | ||
|
|
a6b070201b | ||
|
|
30352e2419 | ||
|
|
bfa38b36b7 | ||
|
|
1b66a528d1 | ||
|
|
bef4092e6e | ||
|
|
9654f7b642 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.1.10
|
||||
current_version = 0.1.13
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
|
||||
# Build outputs
|
||||
build/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
target/
|
||||
|
||||
# Keep web/dist for the Docker image
|
||||
!web/dist
|
||||
|
||||
# Development
|
||||
.git/
|
||||
.github/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Data and logs
|
||||
data/
|
||||
*.log
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Documentation
|
||||
docs/
|
||||
landing/
|
||||
mlx-test/
|
||||
|
||||
# Test files
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
*.spec.ts
|
||||
*.spec.tsx
|
||||
|
||||
# Keep these out
|
||||
.env
|
||||
.env.local
|
||||
*.pem
|
||||
*.key
|
||||
credentials.json
|
||||
+254
-25
@@ -4,9 +4,153 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
PROVIDER_VERSION: "1.0.0"
|
||||
|
||||
jobs:
|
||||
# ============================================
|
||||
# Build TTS Providers (uploaded to R2, not GitHub)
|
||||
# ============================================
|
||||
build-providers:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# PyTorch CPU provider (Windows)
|
||||
- platform: "windows-latest"
|
||||
provider: "pytorch-cpu"
|
||||
python-version: "3.12"
|
||||
# PyTorch CUDA provider (Windows) - large binary, uploaded to R2
|
||||
- platform: "windows-latest"
|
||||
provider: "pytorch-cuda"
|
||||
python-version: "3.12"
|
||||
# PyTorch CPU provider (Linux)
|
||||
- platform: "ubuntu-22.04"
|
||||
provider: "pytorch-cpu"
|
||||
python-version: "3.12"
|
||||
# PyTorch CUDA provider (Linux) - large binary, uploaded to R2
|
||||
- platform: "ubuntu-22.04"
|
||||
provider: "pytorch-cuda"
|
||||
python-version: "3.12"
|
||||
# PyTorch CPU provider (macOS Apple Silicon)
|
||||
- platform: "macos-latest"
|
||||
provider: "pytorch-cpu"
|
||||
python-version: "3.12"
|
||||
# PyTorch CPU provider (macOS Intel)
|
||||
- platform: "macos-15-intel"
|
||||
provider: "pytorch-cpu"
|
||||
python-version: "3.12"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y llvm-dev
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "pip"
|
||||
|
||||
- name: Install CPU-only torch (Linux)
|
||||
if: matrix.provider == 'pytorch-cpu' && matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r providers/pytorch-cpu/requirements.txt
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Install Python dependencies (CPU - non-Linux)
|
||||
if: matrix.provider == 'pytorch-cpu' && matrix.platform != 'ubuntu-22.04'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
pip install -r providers/pytorch-cpu/requirements.txt
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Install Python dependencies (CUDA)
|
||||
if: matrix.provider == 'pytorch-cuda'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||
pip install -r providers/pytorch-cuda/requirements.txt
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Build provider binary
|
||||
shell: bash
|
||||
run: |
|
||||
cd providers/${{ matrix.provider }}
|
||||
python build.py
|
||||
|
||||
- name: Package provider for distribution
|
||||
shell: bash
|
||||
run: |
|
||||
cd providers/${{ matrix.provider }}/dist
|
||||
|
||||
# Add platform suffix for archive name
|
||||
if [ "${{ matrix.platform }}" == "windows-latest" ]; then
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-windows.zip"
|
||||
# On Windows, zip the directory
|
||||
powershell Compress-Archive -Path "tts-provider-${{ matrix.provider }}/*" -DestinationPath "$ARCHIVE_NAME"
|
||||
elif [ "${{ matrix.platform }}" == "macos-latest" ]; then
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-arm64.tar.gz"
|
||||
tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/
|
||||
elif [ "${{ matrix.platform }}" == "macos-15-intel" ]; then
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-x64.tar.gz"
|
||||
tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/
|
||||
else
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-linux.tar.gz"
|
||||
tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/
|
||||
fi
|
||||
|
||||
echo "Created archive: $ARCHIVE_NAME"
|
||||
ls -lh "$ARCHIVE_NAME"
|
||||
|
||||
- name: Upload provider to R2
|
||||
shell: bash
|
||||
env:
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
run: |
|
||||
# Install AWS CLI (compatible with R2)
|
||||
pip install awscli
|
||||
|
||||
# Configure AWS CLI for R2
|
||||
aws configure set aws_access_key_id $R2_ACCESS_KEY_ID
|
||||
aws configure set aws_secret_access_key $R2_SECRET_ACCESS_KEY
|
||||
aws configure set region auto
|
||||
|
||||
# Determine archive name based on platform
|
||||
if [ "${{ matrix.platform }}" == "windows-latest" ]; then
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-windows.zip"
|
||||
elif [ "${{ matrix.platform }}" == "macos-latest" ]; then
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-arm64.tar.gz"
|
||||
elif [ "${{ matrix.platform }}" == "macos-15-intel" ]; then
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-x64.tar.gz"
|
||||
else
|
||||
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-linux.tar.gz"
|
||||
fi
|
||||
|
||||
# Upload to R2 (bucket: voicebox)
|
||||
aws s3 cp "providers/${{ matrix.provider }}/dist/$ARCHIVE_NAME" \
|
||||
"s3://voicebox/providers/v${{ env.PROVIDER_VERSION }}/$ARCHIVE_NAME" \
|
||||
--endpoint-url "$R2_ENDPOINT"
|
||||
|
||||
echo "Uploaded $ARCHIVE_NAME to R2"
|
||||
|
||||
# ============================================
|
||||
# Build Main App (without bundled TTS on Win/Linux)
|
||||
# ============================================
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -14,22 +158,26 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target aarch64-apple-darwin'
|
||||
python-version: '3.12'
|
||||
backend: 'mlx'
|
||||
- platform: 'macos-15-intel'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
python-version: '3.12'
|
||||
backend: 'pytorch'
|
||||
# - platform: 'ubuntu-22.04'
|
||||
# args: ''
|
||||
# python-version: '3.12'
|
||||
# backend: 'pytorch'
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
python-version: '3.12'
|
||||
backend: 'pytorch'
|
||||
# macOS Apple Silicon - MLX bundled (works out of the box)
|
||||
- platform: "macos-latest"
|
||||
args: "--target aarch64-apple-darwin"
|
||||
python-version: "3.12"
|
||||
backend: "mlx"
|
||||
# macOS Intel - PyTorch bundled (smaller user base, keep simple)
|
||||
- platform: "macos-15-intel"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
python-version: "3.12"
|
||||
backend: "pytorch"
|
||||
# Linux - No TTS bundled, providers downloaded separately
|
||||
- platform: "ubuntu-22.04"
|
||||
args: ""
|
||||
python-version: "3.12"
|
||||
backend: "none"
|
||||
# Windows - PyTorch CPU bundled (works out of the box)
|
||||
- platform: "windows-latest"
|
||||
args: ""
|
||||
python-version: "3.12"
|
||||
backend: "pytorch"
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
@@ -40,7 +188,7 @@ jobs:
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev libasound2-dev
|
||||
|
||||
- name: Install LLVM (macOS)
|
||||
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
|
||||
@@ -53,14 +201,24 @@ jobs:
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
cache: "pip"
|
||||
|
||||
- name: Install Python dependencies
|
||||
- name: Install Python dependencies (with TTS)
|
||||
if: matrix.backend != 'none'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Install Python dependencies (without TTS)
|
||||
if: matrix.backend == 'none'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
# Install base requirements without PyTorch/Qwen-TTS
|
||||
pip install fastapi uvicorn sqlalchemy librosa soundfile numpy httpx
|
||||
pip install huggingface_hub # For Whisper downloads
|
||||
|
||||
- name: Install MLX dependencies (Apple Silicon only)
|
||||
if: matrix.backend == 'mlx'
|
||||
run: |
|
||||
@@ -100,7 +258,7 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './tauri/src-tauri -> target'
|
||||
workspaces: "./tauri/src-tauri -> target"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
@@ -136,19 +294,90 @@ jobs:
|
||||
with:
|
||||
projectPath: tauri
|
||||
tagName: v__VERSION__
|
||||
releaseName: 'voicebox v__VERSION__'
|
||||
releaseName: "voicebox v__VERSION__"
|
||||
releaseBody: |
|
||||
## What's Changed
|
||||
See the assets below to download and install this version.
|
||||
|
||||
### Installation
|
||||
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference
|
||||
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference (works out of the box)
|
||||
- **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch
|
||||
- **Windows**: Download the `.msi` installer
|
||||
- **Linux**: Download the `.AppImage` or `.deb` package
|
||||
- **Windows**: Download the `.msi` installer - requires downloading a TTS provider on first use
|
||||
- **Linux**: Download the `.AppImage` or `.deb` package - requires downloading a TTS provider on first use
|
||||
|
||||
### TTS Providers
|
||||
Windows and Linux users will be prompted to download a TTS provider on first launch:
|
||||
- **Windows**: PyTorch CPU (~300MB) or PyTorch CUDA (~2.4GB for NVIDIA GPUs)
|
||||
- **Linux**: PyTorch CUDA (~2.4GB) - requires NVIDIA GPU
|
||||
|
||||
The app includes automatic updates - future updates will be installed automatically.
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
includeUpdaterJson: true
|
||||
|
||||
# ============================================
|
||||
# Build and Push Docker Images
|
||||
# ============================================
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies and build web UI
|
||||
run: |
|
||||
bun install
|
||||
cd web
|
||||
bun run build
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/v* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
else
|
||||
VERSION="dev"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push CPU image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/jamiepine/voicebox:latest
|
||||
ghcr.io/jamiepine/voicebox:${{ steps.version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push CUDA image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.cuda
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
ghcr.io/jamiepine/voicebox:${{ steps.version.outputs.version }}-cuda
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -15,6 +15,7 @@ dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
*.spec
|
||||
target/
|
||||
*.app
|
||||
*.dmg
|
||||
|
||||
@@ -53,6 +53,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Audio export failing when Tauri save dialog returns object instead of string path
|
||||
|
||||
### Added
|
||||
- **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
|
||||
- Includes Python version detection and compatibility warnings
|
||||
|
||||
+150
-9
@@ -14,16 +14,19 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
||||
### Prerequisites
|
||||
|
||||
- **[Bun](https://bun.sh)** - Fast JavaScript runtime and package manager
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
```
|
||||
|
||||
- **[Python 3.11+](https://python.org)** - For backend development
|
||||
|
||||
```bash
|
||||
python --version # Should be 3.11 or higher
|
||||
```
|
||||
|
||||
- **[Rust](https://rustup.rs)** - For Tauri desktop app (installed automatically by Tauri CLI)
|
||||
|
||||
```bash
|
||||
rustc --version # Check if installed
|
||||
```
|
||||
@@ -37,41 +40,46 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
||||
**Manual setup (required for Windows):**
|
||||
|
||||
1. **Fork and clone the repository**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
||||
cd voicebox
|
||||
```
|
||||
|
||||
2. **Install JavaScript dependencies**
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
This installs dependencies for:
|
||||
|
||||
- `app/` - Shared React frontend
|
||||
- `tauri/` - Tauri desktop wrapper
|
||||
- `web/` - Web deployment wrapper
|
||||
|
||||
3. **Set up Python backend**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
|
||||
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate # On macOS/Linux
|
||||
# or
|
||||
venv\Scripts\activate # On Windows
|
||||
|
||||
|
||||
# Install Python dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
|
||||
# Install MLX dependencies (Apple Silicon only - for faster inference)
|
||||
# On Apple Silicon, this enables native Metal acceleration
|
||||
if [[ $(uname -m) == "arm64" ]]; then
|
||||
pip install -r requirements-mlx.txt
|
||||
fi
|
||||
|
||||
|
||||
# Install Qwen3-TTS (required for voice synthesis)
|
||||
pip install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
```
|
||||
@@ -81,19 +89,24 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
||||
Development requires two terminals: one for the Python backend, one for the Tauri app.
|
||||
|
||||
**Terminal 1: Backend server** (start this first)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate # Activate venv if not already active
|
||||
bun run dev:server
|
||||
# Or manually: uvicorn main:app --reload --port 17493
|
||||
```
|
||||
|
||||
Backend will be available at `http://localhost:17493`
|
||||
|
||||
**Terminal 2: Desktop app**
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
- Create a placeholder sidecar binary (for Tauri compilation)
|
||||
- Start Vite dev server on port 5173
|
||||
- Launch Tauri window pointing to localhost:5173
|
||||
@@ -104,26 +117,133 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
||||
> The bundled server binary is only used in production builds.
|
||||
|
||||
**Optional: Web app**
|
||||
|
||||
```bash
|
||||
bun run dev:web
|
||||
```
|
||||
|
||||
Web app will be available at `http://localhost:5174`
|
||||
|
||||
### Model Downloads
|
||||
|
||||
Models are automatically downloaded from HuggingFace Hub on first use:
|
||||
|
||||
- **Whisper** (transcription): Auto-downloads on first transcription
|
||||
- **Qwen3-TTS** (voice cloning): Auto-downloads on first generation (~2-4GB)
|
||||
|
||||
First-time usage will be slower due to model downloads, but subsequent runs will use cached models.
|
||||
|
||||
### TTS Provider Development
|
||||
|
||||
Voicebox uses a modular provider system to support different inference backends. Understanding this architecture is important when working on TTS features.
|
||||
|
||||
#### Provider Types
|
||||
|
||||
**Bundled Providers** — Included with the app binary:
|
||||
|
||||
- `apple-mlx` — Bundled with macOS Apple Silicon builds (`.dmg` for aarch64)
|
||||
- Uses MLX for native Metal acceleration
|
||||
- Configured in `.github/workflows/release.yml` with `backend: "mlx"`
|
||||
|
||||
**Hybrid Provider:**
|
||||
|
||||
- `pytorch-cpu` — Can be bundled OR downloaded depending on platform
|
||||
- **Bundled** with Windows and macOS Intel builds
|
||||
- macOS Intel: `.dmg` for x64 with `backend: "pytorch"`
|
||||
- Windows: `.exe` installer with PyTorch CPU included
|
||||
- **Downloaded** on first use for Linux builds (~300MB)
|
||||
- Falls back to bundled version if external binary not found
|
||||
|
||||
**External-Only Providers:**
|
||||
|
||||
- `pytorch-cuda` — NVIDIA GPU-accelerated provider (~2.4GB)
|
||||
- Windows/Linux only (no NVIDIA GPUs on macOS)
|
||||
- Downloaded on demand, not bundled
|
||||
- Optional for users with CUDA-capable GPUs
|
||||
|
||||
#### Provider Architecture
|
||||
|
||||
```
|
||||
backend/providers/
|
||||
├── __init__.py # ProviderManager - lifecycle management
|
||||
├── base.py # TTSProvider protocol
|
||||
├── bundled.py # BundledProvider - wraps built-in backends
|
||||
├── local.py # LocalProvider - wraps external subprocess
|
||||
├── installer.py # Download and install external providers
|
||||
└── types.py # Provider type definitions
|
||||
|
||||
providers/
|
||||
├── pytorch-cpu/ # External PyTorch CPU provider
|
||||
│ ├── main.py # FastAPI server
|
||||
│ ├── build.py # PyInstaller build script
|
||||
│ └── build_and_install.py # Build and install locally
|
||||
└── pytorch-cuda/ # External PyTorch CUDA provider
|
||||
├── main.py
|
||||
├── build.py
|
||||
└── build_and_install.py
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. **Bundled providers** run in-process within the main backend
|
||||
2. **External providers** run as separate subprocess servers
|
||||
3. **LocalProvider** communicates with external providers via HTTP
|
||||
4. **ProviderManager** handles starting/stopping and health checks
|
||||
|
||||
#### Building Providers Locally
|
||||
|
||||
When developing provider features, you'll need to build and test external providers:
|
||||
|
||||
**Build a single provider:**
|
||||
|
||||
```bash
|
||||
cd providers/pytorch-cpu
|
||||
python build_and_install.py
|
||||
```
|
||||
|
||||
**Build all providers:**
|
||||
|
||||
```bash
|
||||
bun run build:providers
|
||||
```
|
||||
|
||||
This script:
|
||||
|
||||
- Builds the provider binary with PyInstaller
|
||||
- Detects your platform (Windows/macOS/Linux)
|
||||
- Copies to the correct location:
|
||||
- macOS: `~/Library/Application Support/voicebox/providers/`
|
||||
- Windows: `%APPDATA%\voicebox\providers\`
|
||||
- Linux: `~/.local/share/voicebox/providers/`
|
||||
- Sets executable permissions on Unix
|
||||
|
||||
**Testing provider changes:**
|
||||
|
||||
1. Make changes to `providers/pytorch-cpu/main.py`
|
||||
2. Run `bun run build:providers`
|
||||
3. Restart the Voicebox app
|
||||
4. Select the provider in Settings → TTS Provider
|
||||
|
||||
#### Provider Binary Distribution
|
||||
|
||||
For production releases, provider binaries are:
|
||||
|
||||
1. Built by GitHub Actions for all platforms
|
||||
2. Uploaded to Cloudflare R2 at `downloads.voicebox.sh/providers/v{VERSION}/`
|
||||
3. Downloaded on-demand by users based on their platform and GPU
|
||||
|
||||
See `.github/workflows/release.yml` for the build matrix.
|
||||
|
||||
### Building
|
||||
|
||||
**Build everything (recommended):**
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
This automatically:
|
||||
|
||||
1. Builds the Python server binary (`./scripts/build-server.sh`)
|
||||
2. Builds the Tauri desktop app (`cd tauri && bun run tauri build`)
|
||||
|
||||
@@ -132,13 +252,23 @@ Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src
|
||||
**Note:** The build process detects your platform and includes the appropriate backend (MLX for Apple Silicon, PyTorch for others).
|
||||
|
||||
**Build server binary only:**
|
||||
|
||||
```bash
|
||||
bun run build:server
|
||||
# or
|
||||
./scripts/build-server.sh
|
||||
```
|
||||
|
||||
Creates platform-specific binary in `tauri/src-tauri/binaries/`
|
||||
|
||||
**Build provider binaries (for development):**
|
||||
|
||||
```bash
|
||||
bun run build:providers
|
||||
```
|
||||
|
||||
Builds all external provider binaries and installs them to the system provider directory. See [TTS Provider Development](#tts-provider-development) for details.
|
||||
|
||||
**Building with local Qwen3-TTS development version:**
|
||||
|
||||
If you're actively developing or modifying the Qwen3-TTS library, set the `QWEN_TTS_PATH` environment variable to point to your local clone:
|
||||
@@ -151,34 +281,41 @@ bun run build:server
|
||||
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package. Useful when testing changes to the TTS library before they're published to PyPI or when using an editable install (`pip install -e`).
|
||||
|
||||
**Build web app:**
|
||||
|
||||
```bash
|
||||
cd web
|
||||
bun run build
|
||||
```
|
||||
|
||||
Output in `web/dist/`
|
||||
|
||||
### Generate OpenAPI Client
|
||||
|
||||
After starting the backend server:
|
||||
|
||||
```bash
|
||||
./scripts/generate-api.sh
|
||||
```
|
||||
|
||||
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`
|
||||
|
||||
### Convert Assets to Web Formats
|
||||
|
||||
To optimize images and videos for the web, run:
|
||||
|
||||
```bash
|
||||
bun run convert:assets
|
||||
```
|
||||
|
||||
This script:
|
||||
|
||||
- Converts PNG → WebP (better compression, same quality)
|
||||
- Converts MOV → WebM (VP9 codec, smaller file size)
|
||||
- Processes files in `landing/public/` and `docs/public/`
|
||||
- **Deletes original files** after successful conversion
|
||||
|
||||
**Requirements:** Install `webp` and `ffmpeg`:
|
||||
|
||||
```bash
|
||||
brew install webp ffmpeg
|
||||
```
|
||||
@@ -225,6 +362,7 @@ git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
Then create a pull request on GitHub with:
|
||||
|
||||
- Clear description of changes
|
||||
- Screenshots (for UI changes)
|
||||
- Reference to related issues
|
||||
@@ -370,21 +508,23 @@ Currently, testing is primarily manual. When adding tests:
|
||||
Releases are managed by maintainers:
|
||||
|
||||
1. **Bump version using bumpversion:**
|
||||
|
||||
```bash
|
||||
# Install bumpversion (if not already installed)
|
||||
pip install bumpversion
|
||||
|
||||
|
||||
# Bump patch version (0.1.0 -> 0.1.1)
|
||||
bumpversion patch
|
||||
|
||||
|
||||
# Or bump minor version (0.1.0 -> 0.2.0)
|
||||
bumpversion minor
|
||||
|
||||
|
||||
# Or bump major version (0.1.0 -> 1.0.0)
|
||||
bumpversion major
|
||||
```
|
||||
|
||||
|
||||
This automatically:
|
||||
|
||||
- Updates version numbers in all files (`tauri.conf.json`, `Cargo.toml`, all `package.json` files, `backend/main.py`)
|
||||
- Creates a git commit with the version bump
|
||||
- Creates a git tag (e.g., `v0.1.1`, `v0.2.0`)
|
||||
@@ -392,6 +532,7 @@ Releases are managed by maintainers:
|
||||
2. **Update CHANGELOG.md** with release notes
|
||||
|
||||
3. **Push commits and tags:**
|
||||
|
||||
```bash
|
||||
git push
|
||||
git push --tags
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Base Dockerfile for Voicebox (CPU-only)
|
||||
# For GPU support, use Dockerfile.cuda
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Prevent interactive prompts during build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TZ=UTC
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ffmpeg \
|
||||
curl \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy backend
|
||||
COPY backend/ /app/backend/
|
||||
COPY providers/ /app/providers/
|
||||
|
||||
# Copy pre-built web UI
|
||||
COPY web/dist/ /app/web/dist/
|
||||
|
||||
# Install Python dependencies (without PyTorch - will be downloaded via provider system)
|
||||
RUN python -m pip install --upgrade pip && \
|
||||
pip install --no-cache-dir \
|
||||
fastapi uvicorn[standard] pydantic sqlalchemy alembic \
|
||||
librosa soundfile numpy python-multipart Pillow \
|
||||
huggingface_hub transformers accelerate
|
||||
|
||||
# Create data directory for profiles/generations
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run server with web UI
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Dockerfile for Voicebox with NVIDIA GPU support (CUDA)
|
||||
|
||||
FROM nvidia/cuda:12.1.1-runtime-ubuntu22.04
|
||||
|
||||
# Prevent interactive prompts during build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TZ=UTC
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python 3.12
|
||||
RUN apt-get update && apt-get install -y \
|
||||
software-properties-common \
|
||||
&& add-apt-repository ppa:deadsnakes/ppa \
|
||||
&& apt-get update && apt-get install -y \
|
||||
python3.12 \
|
||||
python3.12-dev \
|
||||
python3.12-venv \
|
||||
ffmpeg \
|
||||
curl \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set Python 3.12 as default and bootstrap pip
|
||||
RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1 && \
|
||||
update-alternatives --install /usr/bin/python python /usr/bin/python3.12 1 && \
|
||||
python3.12 -m ensurepip --upgrade && \
|
||||
python3.12 -m pip install --upgrade pip
|
||||
|
||||
# Copy backend
|
||||
COPY backend/ /app/backend/
|
||||
COPY providers/ /app/providers/
|
||||
|
||||
# Copy pre-built web UI
|
||||
COPY web/dist/ /app/web/dist/
|
||||
|
||||
# Install PyTorch with CUDA support first
|
||||
RUN pip install --no-cache-dir \
|
||||
torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||
|
||||
# Install remaining dependencies
|
||||
RUN pip install --no-cache-dir \
|
||||
fastapi uvicorn[standard] pydantic sqlalchemy alembic \
|
||||
transformers accelerate huggingface_hub \
|
||||
librosa soundfile numpy python-multipart Pillow \
|
||||
qwen-tts
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run server with web UI
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
## What is Voicebox?
|
||||
|
||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as the **Ollama for voice** — download models, clone voices, and generate speech entirely on your machine.
|
||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as a **local, free and open-source alternative to ElevenLabs** — download models, clone voices, and generate speech entirely on your machine.
|
||||
|
||||
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives you:
|
||||
|
||||
@@ -68,6 +68,7 @@ Unlike cloud services that lock your voice data behind subscriptions, Voicebox g
|
||||
- **Model flexibility** — currently powered by Qwen3-TTS, with support for XTTS, Bark, and other models coming soon
|
||||
- **API-first** — use the desktop app or integrate voice synthesis into your own projects
|
||||
- **Native performance** — built with Tauri (Rust), not Electron
|
||||
- **Super fast on Mac** — MLX backend with native Metal acceleration for 4-5x faster inference on Apple Silicon
|
||||
|
||||
Download a voice model, clone any voice from a few seconds of audio, and compose multi-voice projects with studio-grade editing tools. No Python install required, no cloud dependency, no limits.
|
||||
|
||||
@@ -75,16 +76,39 @@ Download a voice model, clone any voice from a few seconds of audio, and compose
|
||||
|
||||
## Download
|
||||
|
||||
Voicebox is available now for macOS and Windows.
|
||||
### Desktop App
|
||||
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| macOS (Apple Silicon) | [voicebox_aarch64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_aarch64.app.tar.gz) |
|
||||
| macOS (Intel) | [voicebox_x64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_x64.app.tar.gz) |
|
||||
| Windows (MSI) | [voicebox_0.1.0_x64_en-US.msi](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_0.1.0_x64_en-US.msi) |
|
||||
| Windows (Setup) | [voicebox_0.1.0_x64-setup.exe](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_0.1.0_x64-setup.exe) |
|
||||
| Platform | Download |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| macOS (Apple Silicon) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||
| macOS (Intel) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||
| Windows (MSI) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||
| Windows (Setup) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||
| Linux (AppImage) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||
| Linux (Deb) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||
|
||||
> **Linux builds coming soon** — Currently blocked by GitHub runner disk space limitations.
|
||||
### Docker
|
||||
|
||||
Run Voicebox with the web UI in Docker - perfect for servers and headless deployments:
|
||||
|
||||
```bash
|
||||
# CPU-only (supports amd64 and arm64)
|
||||
docker run -p 8000:8000 -v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest
|
||||
|
||||
# NVIDIA GPU (recommended for performance)
|
||||
docker run --gpus all -p 8000:8000 -v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
```
|
||||
|
||||
Or use Docker Compose:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open http://localhost:8000 to access the web UI.
|
||||
|
||||
See [Docker Deployment Guide](docs/overview/docker.mdx) for cloud deployments, GPU setup, and more.
|
||||
|
||||
---
|
||||
|
||||
@@ -97,6 +121,7 @@ Powered by Alibaba's **Qwen3-TTS** — a breakthrough model that achieves near-p
|
||||
- **Instant cloning** — Upload a sample, get a voice profile
|
||||
- **High fidelity** — Natural prosody, emotion, and cadence
|
||||
- **Multi-language** — English, Chinese, and more coming
|
||||
- **Lightning fast on Mac** — MLX backend leverages Apple Silicon's Neural Engine for super fast generation
|
||||
|
||||
### Voice Profile Management
|
||||
|
||||
@@ -135,9 +160,10 @@ Create multi-voice narratives, podcasts, and conversations with a timeline-based
|
||||
|
||||
### Flexible Deployment
|
||||
|
||||
- **Local mode** — Everything runs on your machine
|
||||
- **Remote mode** — Connect to a GPU server on your network
|
||||
- **One-click server** — Turn any machine into a Voicebox server
|
||||
- **Desktop app** — Native apps for macOS, Windows, and Linux
|
||||
- **Docker** — Deploy to servers with the web UI included
|
||||
- **Remote mode** — Connect desktop app to a remote GPU server
|
||||
- **Cloud ready** — Deploy to AWS, GCP, DigitalOcean, or any cloud provider
|
||||
|
||||
---
|
||||
|
||||
@@ -174,17 +200,17 @@ Full API documentation available at `http://localhost:8000/docs` when running.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Desktop App | Tauri (Rust) |
|
||||
| Frontend | React, TypeScript, Tailwind CSS |
|
||||
| State | Zustand, React Query |
|
||||
| Backend | FastAPI (Python) |
|
||||
| Voice Model | Qwen3-TTS (PyTorch or MLX) |
|
||||
| Transcription | Whisper (PyTorch or MLX) |
|
||||
| Layer | Technology |
|
||||
| ---------------- | --------------------------------------------------- |
|
||||
| Desktop App | Tauri (Rust) |
|
||||
| Frontend | React, TypeScript, Tailwind CSS |
|
||||
| State | Zustand, React Query |
|
||||
| Backend | FastAPI (Python) |
|
||||
| Voice Model | Qwen3-TTS (PyTorch or MLX) |
|
||||
| Transcription | Whisper (PyTorch or MLX) |
|
||||
| Inference Engine | MLX (Apple Silicon) / PyTorch (Windows/Linux/Intel) |
|
||||
| Database | SQLite |
|
||||
| Audio | WaveSurfer.js, librosa |
|
||||
| Database | SQLite |
|
||||
| Audio | WaveSurfer.js, librosa |
|
||||
|
||||
**Why this stack?**
|
||||
|
||||
@@ -192,6 +218,26 @@ Full API documentation available at `http://localhost:8000/docs` when running.
|
||||
- **FastAPI** — Async Python with automatic OpenAPI schema generation
|
||||
- **Type-safe end-to-end** — Generated TypeScript client from OpenAPI spec
|
||||
|
||||
### TTS Provider Architecture
|
||||
|
||||
Voicebox uses a modular provider system to support different inference backends:
|
||||
|
||||
- **`apple-mlx`** — Bundled with macOS Apple Silicon builds
|
||||
|
||||
- Uses MLX with native Metal acceleration (4-5x faster)
|
||||
- Works out of the box, no download required
|
||||
|
||||
- **`pytorch-cpu`** — Universal CPU provider (bundled or downloaded)
|
||||
|
||||
- Bundled with Windows and macOS Intel builds
|
||||
- Downloaded on first use for Linux (~300MB)
|
||||
|
||||
- **`pytorch-cuda`** — Optional NVIDIA GPU-accelerated provider
|
||||
- Windows/Linux only (~2.4GB)
|
||||
- 4-5x faster inference on CUDA-capable GPUs
|
||||
|
||||
macOS and Windows builds work out of the box with bundled providers. Linux users download a provider on first launch. The app automatically detects your hardware and recommends the best option. All downloadable providers are distributed via Cloudflare R2 for fast, global delivery.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
@@ -200,13 +246,13 @@ Voicebox is the beginning of something bigger. Here's what's coming:
|
||||
|
||||
### Coming Soon
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Real-time Synthesis** | Stream audio as it generates, word by word |
|
||||
| **Conversation Mode** | Multi-speaker dialogues with automatic turn-taking |
|
||||
| **Voice Effects** | Pitch shift, reverb, M3GAN-style effects |
|
||||
| **Timeline Editor** | Audio studio with word-level precision editing |
|
||||
| **More Models** | XTTS, Bark, and other open-source voice models |
|
||||
| Feature | Description |
|
||||
| ----------------------- | -------------------------------------------------- |
|
||||
| **Real-time Synthesis** | Stream audio as it generates, word by word |
|
||||
| **Conversation Mode** | Multi-speaker dialogues with automatic turn-taking |
|
||||
| **Voice Effects** | Pitch shift, reverb, M3GAN-style effects |
|
||||
| **Timeline Editor** | Audio studio with word-level precision editing |
|
||||
| **More Models** | XTTS, Bark, and other open-source voice models |
|
||||
|
||||
### Future Vision
|
||||
|
||||
@@ -258,9 +304,10 @@ cd backend && pip install -r requirements.txt && cd ..
|
||||
bun run dev
|
||||
```
|
||||
|
||||
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org).
|
||||
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org).
|
||||
|
||||
**Performance:**
|
||||
|
||||
**Performance:**
|
||||
- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration for 4-5x faster inference
|
||||
- **Windows/Linux/Intel Mac**: Uses PyTorch backend (CUDA GPU recommended, CPU supported but slower)
|
||||
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -17,6 +17,10 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.4",
|
||||
"@iconify-json/svg-spinners": "^1.2.4",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
@@ -24,6 +28,7 @@
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
@@ -43,7 +48,6 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"framer-motion": "^12.29.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"motion": "^12.29.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
|
||||
+35
-21
@@ -1,16 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import ShinyText from '@/components/ShinyText';
|
||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import {
|
||||
isTauri,
|
||||
setKeepServerRunning,
|
||||
setupWindowCloseHandler,
|
||||
startServer,
|
||||
} from '@/lib/tauri';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { router } from '@/router';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
@@ -38,29 +34,45 @@ const LOADING_MESSAGES = [
|
||||
];
|
||||
|
||||
function App() {
|
||||
const platform = usePlatform();
|
||||
const [serverReady, setServerReady] = useState(false);
|
||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||
const serverStartingRef = useRef(false);
|
||||
|
||||
// Automatically check for app updates on startup and show toast notifications
|
||||
useAutoUpdater(true);
|
||||
|
||||
// Sync stored setting to Rust on startup
|
||||
useEffect(() => {
|
||||
if (isTauri()) {
|
||||
if (platform.metadata.isTauri) {
|
||||
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
||||
setKeepServerRunning(keepRunning).catch((error) => {
|
||||
platform.lifecycle.setKeepServerRunning(keepRunning).catch((error) => {
|
||||
console.error('Failed to sync initial setting to Rust:', error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||
|
||||
// Setup lifecycle callbacks
|
||||
useEffect(() => {
|
||||
platform.lifecycle.onServerReady = () => {
|
||||
setServerReady(true);
|
||||
};
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.lifecycle]);
|
||||
|
||||
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||
useEffect(() => {
|
||||
if (!isTauri()) {
|
||||
if (!platform.metadata.isTauri) {
|
||||
setServerReady(true); // Web assumes server is running
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup window close handler to check setting and stop server if needed
|
||||
// This works in both dev and prod, but will only stop server if it was started by the app
|
||||
setupWindowCloseHandler().catch((error) => {
|
||||
platform.lifecycle.setupWindowCloseHandler().catch((error) => {
|
||||
console.error('Failed to setup window close handler:', error);
|
||||
});
|
||||
|
||||
@@ -70,8 +82,7 @@ function App() {
|
||||
console.log('Dev mode: Skipping auto-start of server (run it separately)');
|
||||
setServerReady(true); // Mark as ready so UI doesn't show loading screen
|
||||
// Mark that server was not started by app (so we don't try to stop it on close)
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = false;
|
||||
(window as any).__voiceboxServerStartedByApp = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,19 +94,20 @@ function App() {
|
||||
serverStartingRef.current = true;
|
||||
console.log('Production mode: Starting bundled server...');
|
||||
|
||||
startServer(false)
|
||||
platform.lifecycle
|
||||
.startServer(false)
|
||||
.then((serverUrl) => {
|
||||
console.log('Server is ready at:', serverUrl);
|
||||
// Update the server URL in the store with the dynamically assigned port
|
||||
useServerStore.getState().setServerUrl(serverUrl);
|
||||
setServerReady(true);
|
||||
// Mark that we started the server (so we know to stop it on close)
|
||||
window.__voiceboxServerStartedByApp = true;
|
||||
(window as any).__voiceboxServerStartedByApp = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to auto-start server:', error);
|
||||
serverStartingRef.current = false;
|
||||
window.__voiceboxServerStartedByApp = false;
|
||||
(window as any).__voiceboxServerStartedByApp = false;
|
||||
});
|
||||
|
||||
// Cleanup: stop server on actual unmount (not StrictMode remount)
|
||||
@@ -104,11 +116,13 @@ function App() {
|
||||
// Window close event handles server shutdown based on setting
|
||||
serverStartingRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||
|
||||
// Cycle through loading messages every 3 seconds
|
||||
useEffect(() => {
|
||||
if (!isTauri() || serverReady) {
|
||||
if (!platform.metadata.isTauri || serverReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,10 +131,10 @@ function App() {
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [serverReady]);
|
||||
}, [serverReady, platform.metadata.isTauri]);
|
||||
|
||||
// Show loading screen while server is starting in Tauri
|
||||
if (isTauri() && !serverReady) {
|
||||
if (platform.metadata.isTauri && !serverReady) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { PauseIcon, PlayIcon, RepeatIcon, VolumeHighIcon, VolumeMuteIcon, Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
import { debug } from '@/lib/utils/debug';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function AudioPlayer() {
|
||||
const platform = usePlatform();
|
||||
const {
|
||||
audioUrl,
|
||||
audioId,
|
||||
@@ -39,7 +40,7 @@ export function AudioPlayer() {
|
||||
if (!profileId) return { channel_ids: [] };
|
||||
return apiClient.getProfileChannels(profileId);
|
||||
},
|
||||
enabled: !!profileId && isTauri(),
|
||||
enabled: !!profileId && platform.metadata.isTauri,
|
||||
});
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
@@ -50,7 +51,7 @@ export function AudioPlayer() {
|
||||
|
||||
// Determine if we should use native playback
|
||||
const useNativePlayback = useMemo(() => {
|
||||
if (!isTauri() || !profileChannels || !channels) {
|
||||
if (!platform.metadata.isTauri || !profileChannels || !channels) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -195,7 +196,7 @@ export function AudioPlayer() {
|
||||
let runtimeProfileChannels = null;
|
||||
let runtimeChannels = null;
|
||||
|
||||
if (isTauri() && currentProfileId) {
|
||||
if (platform.metadata.isTauri && currentProfileId) {
|
||||
try {
|
||||
runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
|
||||
debug.log('Runtime profileChannels:', runtimeProfileChannels);
|
||||
@@ -210,7 +211,7 @@ export function AudioPlayer() {
|
||||
}
|
||||
|
||||
debug.log('Auto-play check:', {
|
||||
isTauri: isTauri(),
|
||||
isTauri: platform.metadata.isTauri,
|
||||
currentAudioUrl,
|
||||
currentProfileId,
|
||||
hasProfileChannels: !!runtimeProfileChannels,
|
||||
@@ -218,7 +219,7 @@ export function AudioPlayer() {
|
||||
});
|
||||
|
||||
if (
|
||||
isTauri() &&
|
||||
platform.metadata.isTauri &&
|
||||
currentAudioUrl &&
|
||||
currentProfileId &&
|
||||
runtimeProfileChannels &&
|
||||
@@ -229,7 +230,7 @@ export function AudioPlayer() {
|
||||
// Stop any existing native playback first
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
try {
|
||||
await invoke('stop_audio_playback');
|
||||
platform.audio.stopPlayback();
|
||||
debug.log('Stopped existing native playback before starting new one');
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop existing playback:', error);
|
||||
@@ -279,11 +280,8 @@ export function AudioPlayer() {
|
||||
// Play via native audio
|
||||
debug.log('Invoking play_audio_to_devices...');
|
||||
try {
|
||||
const result = await invoke('play_audio_to_devices', {
|
||||
audioData: Array.from(audioData),
|
||||
deviceIds: deviceIds,
|
||||
});
|
||||
debug.log('play_audio_to_devices completed successfully, result:', result);
|
||||
await platform.audio.playToDevices(audioData, deviceIds);
|
||||
debug.log('play_audio_to_devices completed successfully');
|
||||
|
||||
// Mark that we're using native playback
|
||||
isUsingNativePlaybackRef.current = true;
|
||||
@@ -462,7 +460,7 @@ export function AudioPlayer() {
|
||||
// Use double requestAnimationFrame to ensure DOM is fully rendered
|
||||
let rafId1: number;
|
||||
let rafId2: number;
|
||||
let timeoutId: number | null = null;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
rafId1 = requestAnimationFrame(() => {
|
||||
rafId2 = requestAnimationFrame(() => {
|
||||
@@ -516,15 +514,13 @@ export function AudioPlayer() {
|
||||
}
|
||||
|
||||
// Stop native playback if it was active
|
||||
if (isUsingNativePlaybackRef.current && isTauri()) {
|
||||
(async () => {
|
||||
try {
|
||||
await invoke('stop_audio_playback');
|
||||
debug.log('Stopped native audio playback');
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop native playback:', error);
|
||||
}
|
||||
})();
|
||||
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
|
||||
try {
|
||||
platform.audio.stopPlayback();
|
||||
debug.log('Stopped native audio playback');
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop native playback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset native playback flag when loading new audio
|
||||
@@ -711,7 +707,7 @@ export function AudioPlayer() {
|
||||
if (isPlaying) {
|
||||
// Pause: stop native playback and pause WaveSurfer visualization
|
||||
try {
|
||||
await invoke('stop_audio_playback');
|
||||
platform.audio.stopPlayback();
|
||||
debug.log('Stopped native audio playback');
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop native playback:', error);
|
||||
@@ -724,7 +720,7 @@ export function AudioPlayer() {
|
||||
try {
|
||||
// Stop any existing native playback first
|
||||
try {
|
||||
await invoke('stop_audio_playback');
|
||||
platform.audio.stopPlayback();
|
||||
} catch (_error) {
|
||||
// Ignore errors when stopping (might not be playing)
|
||||
debug.log('No existing playback to stop');
|
||||
@@ -742,10 +738,7 @@ export function AudioPlayer() {
|
||||
const audioData = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
// Play via native audio
|
||||
await invoke('play_audio_to_devices', {
|
||||
audioData: Array.from(audioData),
|
||||
deviceIds: deviceIds,
|
||||
});
|
||||
await platform.audio.playToDevices(audioData, deviceIds);
|
||||
|
||||
// Mark that we're using native playback
|
||||
isUsingNativePlaybackRef.current = true;
|
||||
@@ -806,10 +799,12 @@ export function AudioPlayer() {
|
||||
|
||||
const handleClose = () => {
|
||||
// Stop any native playback
|
||||
if (isUsingNativePlaybackRef.current && isTauri()) {
|
||||
invoke('stop_audio_playback').catch((error) => {
|
||||
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
|
||||
try {
|
||||
platform.audio.stopPlayback();
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop native playback:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Stop WaveSurfer
|
||||
if (wavesurferRef.current) {
|
||||
@@ -838,7 +833,7 @@ export function AudioPlayer() {
|
||||
className="shrink-0"
|
||||
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={20} className="h-5 w-5" /> : <HugeiconsIcon icon={PlayIcon} size={20} className="h-5 w-5" />}
|
||||
</Button>
|
||||
|
||||
{/* Waveform */}
|
||||
@@ -879,7 +874,7 @@ export function AudioPlayer() {
|
||||
className={isLooping ? 'text-primary' : ''}
|
||||
title="Toggle loop"
|
||||
>
|
||||
<Repeat className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={RepeatIcon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Volume Control */}
|
||||
@@ -890,7 +885,7 @@ export function AudioPlayer() {
|
||||
onClick={() => setVolume(volume > 0 ? 0 : 1)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
||||
{volume > 0 ? <HugeiconsIcon icon={VolumeHighIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={VolumeMuteIcon} size={16} className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Slider
|
||||
value={[volume * 100]}
|
||||
@@ -909,7 +904,7 @@ export function AudioPlayer() {
|
||||
className="shrink-0"
|
||||
title="Close player"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={20} className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { CheckmarkCircle01Icon, CheckmarkCircle02Icon, Edit01Icon, Add01Icon, SpeakerIcon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -23,9 +23,9 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
interface AudioDevice {
|
||||
id: string;
|
||||
@@ -34,6 +34,7 @@ interface AudioDevice {
|
||||
}
|
||||
|
||||
export function AudioTab() {
|
||||
const platform = usePlatform();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
||||
@@ -49,18 +50,17 @@ export function AudioTab() {
|
||||
const { data: devices, isLoading: devicesLoading } = useQuery({
|
||||
queryKey: ['audio-devices'],
|
||||
queryFn: async () => {
|
||||
if (!isTauri()) {
|
||||
if (!platform.metadata.isTauri) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const result = await invoke<AudioDevice[]>('list_audio_output_devices');
|
||||
return result;
|
||||
return await platform.audio.listOutputDevices();
|
||||
} catch (error) {
|
||||
console.error('Failed to list audio devices:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: isTauri(),
|
||||
enabled: platform.metadata.isTauri,
|
||||
});
|
||||
|
||||
const { data: profiles } = useQuery({
|
||||
@@ -136,7 +136,7 @@ export function AudioTab() {
|
||||
<div className="flex items-center justify-between mb-6 shrink-0">
|
||||
<h2 className="text-2xl font-bold">Audio Channels</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
New Channel
|
||||
</Button>
|
||||
</div>
|
||||
@@ -151,13 +151,13 @@ export function AudioTab() {
|
||||
>
|
||||
{allChannels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||
<Speaker className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<HugeiconsIcon icon={SpeakerIcon} size={48} className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No audio channels yet. Create your first channel to route voices to specific
|
||||
devices.
|
||||
</p>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Create Channel
|
||||
</Button>
|
||||
</div>
|
||||
@@ -179,7 +179,7 @@ export function AudioTab() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Speaker className="h-4 w-4 text-muted-foreground" />
|
||||
<HugeiconsIcon icon={SpeakerIcon} size={16} className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
|
||||
@@ -236,7 +236,7 @@ export function AudioTab() {
|
||||
setEditingChannel(channel.id);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Edit01Icon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -249,7 +249,7 @@ export function AudioTab() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -326,10 +326,10 @@ export function AudioTab() {
|
||||
isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30',
|
||||
)}
|
||||
>
|
||||
{isConnected && <Check className="h-3 w-3 text-accent-foreground" />}
|
||||
{isConnected && <HugeiconsIcon icon={CheckmarkCircle01Icon} size={12} className="h-3 w-3 text-accent-foreground" />}
|
||||
</div>
|
||||
) : device.is_default ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} size={16} className="h-4 w-4 text-primary shrink-0" />
|
||||
) : null}
|
||||
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
|
||||
{device.name}
|
||||
@@ -340,9 +340,9 @@ export function AudioTab() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} size={48} className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
{isTauri() ? 'No audio devices found' : 'Audio device selection requires Tauri'}
|
||||
{platform.metadata.isTauri ? 'No audio devices found' : 'Audio device selection requires Tauri'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -495,7 +495,7 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={12} className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -603,7 +603,7 @@ function EditChannelDialog({
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={12} className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -649,7 +649,7 @@ function EditChannelDialog({
|
||||
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={12} className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SparklesIcon, TextSquareIcon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useMatchRoute } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Loader2, MessageSquare, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
@@ -187,7 +189,7 @@ export function FloatingGenerateBox({
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 overflow-hidden p-3"
|
||||
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 p-3"
|
||||
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
||||
>
|
||||
<Form {...form}>
|
||||
@@ -274,7 +276,7 @@ export function FloatingGenerateBox({
|
||||
field.ref(node);
|
||||
}
|
||||
}}
|
||||
placeholder="Add delivery instructions..."
|
||||
placeholder="e.g. very happy and excited"
|
||||
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
||||
style={{
|
||||
minHeight: isExpanded ? '100px' : '32px',
|
||||
@@ -294,18 +296,27 @@ export function FloatingGenerateBox({
|
||||
</motion.div>
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !selectedProfileId}
|
||||
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
|
||||
size="icon"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !selectedProfileId}
|
||||
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg transition-all duration-200"
|
||||
size="icon"
|
||||
>
|
||||
{isPending ? (
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={SparklesIcon} size={16} className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
{isPending
|
||||
? 'Generating...'
|
||||
: !selectedProfileId
|
||||
? 'Select a voice profile first'
|
||||
: 'Generate speech'}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
@@ -315,20 +326,25 @@ export function FloatingGenerateBox({
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute top-0 right-[calc(100%+0.5rem)]"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsInstructMode(!isInstructMode)}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-full transition-all duration-200',
|
||||
isInstructMode
|
||||
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||
: 'bg-card border border-border hover:bg-background/50',
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsInstructMode(!isInstructMode)}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-full transition-all duration-200',
|
||||
isInstructMode
|
||||
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||
: 'bg-card border border-border hover:bg-background/50',
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon icon={TextSquareIcon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
Fine tune instructions
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Loader2, Mic } from 'lucide-react';
|
||||
import { Mic01Icon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -46,7 +48,11 @@ export function GenerationForm() {
|
||||
<FormLabel>Voice Profile</FormLabel>
|
||||
{selectedProfile ? (
|
||||
<div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
|
||||
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||
<HugeiconsIcon
|
||||
icon={Mic01Icon}
|
||||
size={16}
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<span className="font-medium">{selectedProfile.name}</span>
|
||||
<span className="text-sm text-muted-foreground">{selectedProfile.language}</span>
|
||||
</div>
|
||||
@@ -170,14 +176,10 @@ export function GenerationForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isPending || !selectedProfileId}
|
||||
>
|
||||
<Button type="submit" className="w-full" disabled={isPending || !selectedProfileId}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon icon="svg-spinners:ring-resize" className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { AudioWaveform, Download, FileArchive, 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 { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -18,6 +27,7 @@ import {
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { HistoryResponse } from '@/lib/api/types';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import {
|
||||
useDeleteGeneration,
|
||||
@@ -33,18 +43,29 @@ import { usePlayerStore } from '@/stores/playerStore';
|
||||
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
||||
// This is the new alternate history view with fixed height rows
|
||||
|
||||
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS
|
||||
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
|
||||
export function HistoryTable() {
|
||||
const [page, _setPage] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [allHistory, setAllHistory] = useState<HistoryResponse[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(
|
||||
null,
|
||||
);
|
||||
const limit = 20;
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: historyData, isLoading } = useHistory({
|
||||
const {
|
||||
data: historyData,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useHistory({
|
||||
limit,
|
||||
offset: page * limit,
|
||||
});
|
||||
@@ -60,6 +81,56 @@ export function HistoryTable() {
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
|
||||
// Update accumulated history when new data arrives
|
||||
useEffect(() => {
|
||||
if (historyData?.items) {
|
||||
setTotal(historyData.total);
|
||||
if (page === 0) {
|
||||
// Reset to first page
|
||||
setAllHistory(historyData.items);
|
||||
} else {
|
||||
// Append new items, avoiding duplicates
|
||||
setAllHistory((prev) => {
|
||||
const existingIds = new Set(prev.map((item) => item.id));
|
||||
const newItems = historyData.items.filter((item) => !existingIds.has(item.id));
|
||||
return [...prev, ...newItems];
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [historyData, page]);
|
||||
|
||||
// Reset to page 0 when deletions or imports occur
|
||||
useEffect(() => {
|
||||
if (deleteGeneration.isSuccess || importGeneration.isSuccess) {
|
||||
setPage(0);
|
||||
setAllHistory([]);
|
||||
}
|
||||
}, [deleteGeneration.isSuccess, importGeneration.isSuccess]);
|
||||
|
||||
// Intersection Observer for infinite scroll
|
||||
useEffect(() => {
|
||||
const loadMoreEl = loadMoreRef.current;
|
||||
if (!loadMoreEl) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const target = entries[0];
|
||||
if (target.isIntersecting && !isFetching && allHistory.length < total) {
|
||||
setPage((prev) => prev + 1);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollRef.current,
|
||||
rootMargin: '100px',
|
||||
threshold: 0.1,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(loadMoreEl);
|
||||
return () => observer.disconnect();
|
||||
}, [isFetching, allHistory.length, total]);
|
||||
|
||||
// Track scroll position for gradient effect
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return;
|
||||
@@ -113,24 +184,16 @@ export function HistoryTable() {
|
||||
);
|
||||
};
|
||||
|
||||
const _handleImportClick = () => {
|
||||
file_handleImportClickk.click();
|
||||
const handleDeleteClick = (generationId: string, profileName: string) => {
|
||||
setGenerationToDelete({ id: generationId, name: profileName });
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const _handleFileChange = (_e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
cons_handleFileChangeet.files?.[0];
|
||||
if (file) {
|
||||
// Validate file extension
|
||||
if (!file.name.endsWith('.voicebox.zip')) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please select a valid .voicebox.zip file',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setImportDialogOpen(true);
|
||||
const handleDeleteConfirm = () => {
|
||||
if (generationToDelete) {
|
||||
deleteGeneration.mutate(generationToDelete.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setGenerationToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -159,13 +222,19 @@ export function HistoryTable() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
if (isLoading && page === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Icon
|
||||
icon="svg-spinners:ring-resize"
|
||||
className="h-8 w-8 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const history = historyData?.items || [];
|
||||
const total = historyData?.total || 0;
|
||||
const _hasMore = history.length === limit && (page + 1) * limit < total;
|
||||
const history = allHistory;
|
||||
const hasMore = allHistory.length < total;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 relative">
|
||||
@@ -205,7 +274,11 @@ export function HistoryTable() {
|
||||
>
|
||||
{/* Waveform icon */}
|
||||
<div className="flex items-center shrink-0">
|
||||
<AudioWaveform className="h-5 w-5 text-muted-foreground" />
|
||||
<HugeiconsIcon
|
||||
icon={WaveIcon}
|
||||
size={20}
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Left side - Meta information */}
|
||||
@@ -229,6 +302,7 @@ export function HistoryTable() {
|
||||
<Textarea
|
||||
value={gen.text}
|
||||
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -246,36 +320,35 @@ export function HistoryTable() {
|
||||
className="h-8 w-8"
|
||||
aria-label="Actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={PlayIcon} size={16} className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||
disabled={exportGenerationAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Export Audio
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExportPackage(gen.id, gen.text)}
|
||||
disabled={exportGeneration.isPending}
|
||||
>
|
||||
<FileArchive className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Archive01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Export Package
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => deleteGeneration.mutate(gen.id)}
|
||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||
disabled={deleteGeneration.isPending}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -284,10 +357,59 @@ export function HistoryTable() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Load more trigger element */}
|
||||
{hasMore && (
|
||||
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
|
||||
{isFetching && (
|
||||
<Icon
|
||||
icon="svg-spinners:ring-resize"
|
||||
className="h-6 w-6 animate-spin text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End of list indicator */}
|
||||
{!hasMore && history.length > 0 && (
|
||||
<div className="text-center py-4 text-xs text-muted-foreground">
|
||||
You've reached the end
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Generation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this generation from "{generationToDelete?.name}"?
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setGenerationToDelete(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteGeneration.isPending}
|
||||
>
|
||||
{deleteGeneration.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Sparkles, Upload } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { SparklesIcon, Upload01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useRef, useState } from 'react';
|
||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||
@@ -89,7 +90,7 @@ export function MainEditor() {
|
||||
<h2 className="text-2xl font-bold">Voicebox</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleImportClick}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Upload01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Import Voice
|
||||
</Button>
|
||||
<input
|
||||
@@ -100,7 +101,7 @@ export function MainEditor() {
|
||||
className="hidden"
|
||||
/>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={SparklesIcon} size={16} className="mr-2 h-4 w-4" />
|
||||
Create Voice
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { setKeepServerRunning } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
const connectionSchema = z.object({
|
||||
serverUrl: z.string().url('Please enter a valid URL'),
|
||||
@@ -26,6 +26,7 @@ const connectionSchema = z.object({
|
||||
type ConnectionFormValues = z.infer<typeof connectionSchema>;
|
||||
|
||||
export function ConnectionForm() {
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
||||
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
||||
@@ -89,7 +90,7 @@ export function ConnectionForm() {
|
||||
checked={keepServerRunningOnClose}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setKeepServerRunningOnClose(checked);
|
||||
setKeepServerRunning(checked).catch((error) => {
|
||||
platform.lifecycle.setKeepServerRunning(checked).catch((error) => {
|
||||
console.error('Failed to sync setting to Rust:', error);
|
||||
});
|
||||
toast({
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Folder01Icon, FolderOpenIcon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useSystemFolders } from '@/lib/hooks/useSystemFolders';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
interface FolderRowProps {
|
||||
label: string;
|
||||
description: string;
|
||||
path: string | undefined;
|
||||
isLoading: boolean;
|
||||
canOpen: boolean;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
function FolderRow({ label, description, path, isLoading, canOpen, onOpen }: FolderRowProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<div className="text-xs text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
{canOpen && path && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onOpen}
|
||||
disabled={isLoading || !path}
|
||||
className="shrink-0"
|
||||
>
|
||||
<HugeiconsIcon icon={FolderOpenIcon} size={16} className="h-4 w-4 mr-2" />
|
||||
Open
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={isLoading ? 'Loading...' : path || 'Not available'}
|
||||
readOnly
|
||||
className="font-mono text-xs text-muted-foreground select-all cursor-text"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataFolders() {
|
||||
const { data: folders, isLoading, error } = useSystemFolders();
|
||||
const platform = usePlatform();
|
||||
const isTauri = platform.metadata.isTauri;
|
||||
|
||||
const handleOpenFolder = async (path: string | undefined) => {
|
||||
if (!path) return;
|
||||
const success = await platform.filesystem.openFolder(path);
|
||||
if (!success && isTauri) {
|
||||
console.error('Failed to open folder:', path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HugeiconsIcon icon={Folder01Icon} size={20} className="h-5 w-5" />
|
||||
Data Folders
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isTauri
|
||||
? 'Click "Open" to view folders in your file explorer, or copy the paths below.'
|
||||
: 'These are the server-side folder paths where your data is stored.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error ? (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<Icon icon="lucide:alert-circle" className="h-4 w-4" />
|
||||
<span>Failed to load folder paths: {error.message}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FolderRow
|
||||
label="App Data"
|
||||
description="Voices, generations, and app database"
|
||||
path={folders?.data_dir}
|
||||
isLoading={isLoading}
|
||||
canOpen={isTauri}
|
||||
onOpen={() => handleOpenFolder(folders?.data_dir)}
|
||||
/>
|
||||
<FolderRow
|
||||
label="Models"
|
||||
description="Downloaded AI models from HuggingFace Hub"
|
||||
path={folders?.models_dir}
|
||||
isLoading={isLoading}
|
||||
canOpen={isTauri}
|
||||
onOpen={() => handleOpenFolder(folders?.models_dir)}
|
||||
/>
|
||||
<FolderRow
|
||||
label="Providers"
|
||||
description="External TTS provider binaries (PyTorch CPU/CUDA)"
|
||||
path={folders?.providers_dir}
|
||||
isLoading={isLoading}
|
||||
canOpen={isTauri}
|
||||
onOpen={() => handleOpenFolder(folders?.providers_dir)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Delete01Icon, Download01Icon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Download, Loader2, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -17,7 +19,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import { ModelProgress } from './ModelProgress';
|
||||
|
||||
export function ModelManagement() {
|
||||
const { toast } = useToast();
|
||||
@@ -27,15 +28,36 @@ export function ModelManagement() {
|
||||
|
||||
const { data: modelStatus, isLoading } = useQuery({
|
||||
queryKey: ['modelStatus'],
|
||||
queryFn: () => apiClient.getModelStatus(),
|
||||
queryFn: async () => {
|
||||
console.log('[Query] Fetching model status');
|
||||
const result = await apiClient.getModelStatus();
|
||||
console.log('[Query] Model status fetched:', result);
|
||||
return result;
|
||||
},
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
});
|
||||
|
||||
// Callbacks for download completion
|
||||
const handleDownloadComplete = useCallback(() => {
|
||||
console.log('[ModelManagement] Download complete, clearing state');
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
}, [queryClient]);
|
||||
|
||||
const handleDownloadError = useCallback(() => {
|
||||
console.log('[ModelManagement] Download error, clearing state');
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
}, []);
|
||||
|
||||
// Use progress toast hook for the downloading model
|
||||
useModelDownloadToast({
|
||||
modelName: downloadingModel || '',
|
||||
displayName: downloadingDisplayName || '',
|
||||
enabled: !!downloadingModel && !!downloadingDisplayName,
|
||||
onComplete: handleDownloadComplete,
|
||||
onError: handleDownloadError,
|
||||
});
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@@ -45,44 +67,69 @@ export function ModelManagement() {
|
||||
sizeMb?: number;
|
||||
} | null>(null);
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: (modelName: string) => {
|
||||
const handleDownload = async (modelName: string) => {
|
||||
console.log('[Download] Button clicked for:', modelName, 'at', new Date().toISOString());
|
||||
|
||||
// Find display name
|
||||
const model = modelStatus?.models.find((m) => m.model_name === modelName);
|
||||
const displayName = model?.display_name || modelName;
|
||||
|
||||
try {
|
||||
// IMPORTANT: Call the API FIRST before setting state
|
||||
// Setting state enables the SSE EventSource in useModelDownloadToast,
|
||||
// which can block/delay the download fetch due to HTTP/1.1 connection limits
|
||||
console.log('[Download] Calling download API for:', modelName);
|
||||
const result = await apiClient.triggerModelDownload(modelName);
|
||||
console.log('[Download] Download API responded:', result);
|
||||
|
||||
// NOW set state to enable SSE tracking (after download has started on backend)
|
||||
setDownloadingModel(modelName);
|
||||
// Find display name from model status
|
||||
const model = modelStatus?.models.find((m) => m.model_name === modelName);
|
||||
setDownloadingDisplayName(model?.display_name || modelName);
|
||||
return apiClient.triggerModelDownload(modelName);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Download completed - clear state and refetch status
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
setDownloadingDisplayName(displayName);
|
||||
|
||||
// Download initiated successfully - state will be cleared when SSE reports completion
|
||||
// or by the polling interval detecting the model is downloaded
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
} catch (error) {
|
||||
console.error('[Download] Download failed:', error);
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
toast({
|
||||
title: 'Download failed',
|
||||
description: error.message,
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (modelName: string) => apiClient.deleteModel(modelName),
|
||||
onSuccess: () => {
|
||||
mutationFn: async (modelName: string) => {
|
||||
console.log('[Delete] Deleting model:', modelName);
|
||||
const result = await apiClient.deleteModel(modelName);
|
||||
console.log('[Delete] Model deleted successfully:', modelName);
|
||||
return result;
|
||||
},
|
||||
onSuccess: async (_data, _modelName) => {
|
||||
console.log('[Delete] onSuccess - showing toast and invalidating queries');
|
||||
toast({
|
||||
title: 'Model deleted',
|
||||
description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`,
|
||||
});
|
||||
setDeleteDialogOpen(false);
|
||||
setModelToDelete(null);
|
||||
// Refetch status to update UI
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
// Invalidate AND explicitly refetch to ensure UI updates
|
||||
// Using refetchType: 'all' ensures we refetch even if the query is stale
|
||||
console.log('[Delete] Invalidating modelStatus query');
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['modelStatus'],
|
||||
refetchType: 'all',
|
||||
});
|
||||
// Also explicitly refetch to guarantee fresh data
|
||||
console.log('[Delete] Explicitly refetching modelStatus query');
|
||||
await queryClient.refetchQueries({ queryKey: ['modelStatus'] });
|
||||
console.log('[Delete] Query refetched');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.log('[Delete] onError:', error);
|
||||
toast({
|
||||
title: 'Delete failed',
|
||||
description: error.message,
|
||||
@@ -108,7 +155,10 @@ export function ModelManagement() {
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Icon
|
||||
icon="svg-spinners:ring-resize"
|
||||
className="h-6 w-6 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
) : modelStatus ? (
|
||||
<div className="space-y-4">
|
||||
@@ -124,7 +174,7 @@ export function ModelManagement() {
|
||||
<ModelItem
|
||||
key={model.model_name}
|
||||
model={model}
|
||||
onDownload={() => downloadMutation.mutate(model.model_name)}
|
||||
onDownload={() => handleDownload(model.model_name)}
|
||||
onDelete={() => {
|
||||
setModelToDelete({
|
||||
name: model.model_name,
|
||||
@@ -152,7 +202,7 @@ export function ModelManagement() {
|
||||
<ModelItem
|
||||
key={model.model_name}
|
||||
model={model}
|
||||
onDownload={() => downloadMutation.mutate(model.model_name)}
|
||||
onDownload={() => handleDownload(model.model_name)}
|
||||
onDelete={() => {
|
||||
setModelToDelete({
|
||||
name: model.model_name,
|
||||
@@ -167,22 +217,6 @@ export function ModelManagement() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicators */}
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
|
||||
Download Progress
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{modelStatus.models.map((model) => (
|
||||
<ModelProgress
|
||||
key={model.model_name}
|
||||
modelName={model.model_name}
|
||||
displayName={model.display_name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
@@ -216,7 +250,7 @@ export function ModelManagement() {
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
@@ -235,16 +269,20 @@ interface ModelItemProps {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
downloaded: boolean;
|
||||
downloading?: boolean; // From server - true if download in progress
|
||||
size_mb?: number;
|
||||
loaded: boolean;
|
||||
};
|
||||
onDownload: () => void;
|
||||
onDelete: () => void;
|
||||
isDownloading: boolean;
|
||||
isDownloading: boolean; // Local state - true if user just clicked download
|
||||
formatSize: (sizeMb?: number) => string;
|
||||
}
|
||||
|
||||
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
|
||||
// Use server's downloading state OR local state (for immediate feedback before server updates)
|
||||
const showDownloading = model.downloading || isDownloading;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
@@ -255,20 +293,21 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
|
||||
Loaded
|
||||
</Badge>
|
||||
)}
|
||||
{model.downloaded && !model.loaded && (
|
||||
{/* Only show Downloaded if actually downloaded AND not downloading */}
|
||||
{model.downloaded && !model.loaded && !showDownloading && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Downloaded
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{model.downloaded && model.size_mb && (
|
||||
{model.downloaded && model.size_mb && !showDownloading && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Size: {formatSize(model.size_mb)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{model.downloaded ? (
|
||||
{model.downloaded && !showDownloading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span>Ready</span>
|
||||
@@ -280,22 +319,18 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
|
||||
disabled={model.loaded}
|
||||
title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : showDownloading ? (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 mr-2 animate-spin" />
|
||||
Downloading...
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={onDownload} disabled={isDownloading} variant="outline">
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" onClick={onDownload} variant="outline">
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Loader2, XCircle } from 'lucide-react';
|
||||
import { CancelCircleIcon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
@@ -8,14 +10,27 @@ import { useServerStore } from '@/stores/serverStore';
|
||||
interface ModelProgressProps {
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
/** Only connect to SSE when actively downloading - prevents connection exhaustion */
|
||||
isDownloading?: boolean;
|
||||
}
|
||||
|
||||
export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
export function ModelProgress({
|
||||
modelName,
|
||||
displayName,
|
||||
isDownloading = false,
|
||||
}: ModelProgressProps) {
|
||||
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverUrl) return;
|
||||
// IMPORTANT: Only connect to SSE when this specific model is downloading
|
||||
// Opening SSE connections for all models exhausts HTTP/1.1 connection limits (6 per origin)
|
||||
// which causes other fetches (like the download trigger) to be queued/blocked
|
||||
if (!serverUrl || !isDownloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[ModelProgress] Connecting SSE for ${modelName}`);
|
||||
|
||||
// Subscribe to progress updates via Server-Sent Events
|
||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||
@@ -27,6 +42,7 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
|
||||
// Close connection if complete or error
|
||||
if (data.status === 'complete' || data.status === 'error') {
|
||||
console.log(`[ModelProgress] Download ${data.status} for ${modelName}, closing SSE`);
|
||||
eventSource.close();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -35,14 +51,15 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
console.error(`[ModelProgress] SSE error for ${modelName}:`, error);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
console.log(`[ModelProgress] Cleanup - closing SSE for ${modelName}`);
|
||||
eventSource.close();
|
||||
};
|
||||
}, [serverUrl, modelName]);
|
||||
}, [serverUrl, modelName, isDownloading]);
|
||||
|
||||
// Don't render if no progress or if complete/error and some time has passed
|
||||
if (
|
||||
@@ -63,10 +80,12 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
const getStatusIcon = () => {
|
||||
switch (progress.status) {
|
||||
case 'error':
|
||||
return <XCircle className="h-4 w-4 text-destructive" />;
|
||||
return (
|
||||
<HugeiconsIcon icon={CancelCircleIcon} size={16} className="h-4 w-4 text-destructive" />
|
||||
);
|
||||
case 'downloading':
|
||||
case 'extracting':
|
||||
return <Loader2 className="h-4 w-4 animate-spin" />;
|
||||
return <Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
import { Delete01Icon, Download01Icon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
|
||||
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
||||
const isWindows = () => navigator.platform.toLowerCase().includes('win');
|
||||
const getPlatformName = () => {
|
||||
if (isMacOS()) return 'macOS';
|
||||
if (isWindows()) return 'Windows';
|
||||
return 'Linux';
|
||||
};
|
||||
|
||||
type ProviderType =
|
||||
| 'auto'
|
||||
| 'apple-mlx'
|
||||
| 'bundled-pytorch'
|
||||
| 'pytorch-cpu'
|
||||
| 'pytorch-cuda'
|
||||
| 'remote'
|
||||
| 'openai';
|
||||
|
||||
export function ProviderSettings() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [downloadingProvider, setDownloadingProvider] = useState<string | null>(null);
|
||||
|
||||
const { data: providersData, isLoading } = useQuery({
|
||||
queryKey: ['providers'],
|
||||
queryFn: async () => {
|
||||
return await apiClient.listProviders();
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: activeProvider } = useQuery({
|
||||
queryKey: ['activeProvider'],
|
||||
queryFn: async () => {
|
||||
return await apiClient.getActiveProvider();
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
// Callbacks for download completion
|
||||
const handleDownloadComplete = useCallback(() => {
|
||||
setDownloadingProvider(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||
}, [queryClient]);
|
||||
|
||||
const handleDownloadError = useCallback(() => {
|
||||
setDownloadingProvider(null);
|
||||
}, []);
|
||||
|
||||
// Use progress toast hook for the downloading provider
|
||||
useModelDownloadToast({
|
||||
modelName: downloadingProvider || '',
|
||||
displayName: downloadingProvider || '',
|
||||
enabled: !!downloadingProvider,
|
||||
onComplete: handleDownloadComplete,
|
||||
onError: handleDownloadError,
|
||||
});
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [providerToDelete, setProviderToDelete] = useState<string | null>(null);
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: async (providerType: string) => {
|
||||
return await apiClient.downloadProvider(providerType);
|
||||
},
|
||||
onSuccess: (_, providerType) => {
|
||||
setDownloadingProvider(providerType);
|
||||
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Download failed',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: async (providerType: string) => {
|
||||
return await apiClient.startProvider(providerType);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activeProvider'] });
|
||||
toast({
|
||||
title: 'Provider started',
|
||||
description: 'The provider has been started successfully',
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to start provider',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (providerType: string) => {
|
||||
return await apiClient.deleteProvider(providerType);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||
toast({
|
||||
title: 'Provider deleted',
|
||||
description: 'The provider has been deleted successfully',
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to delete provider',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownload = async (providerType: string) => {
|
||||
downloadMutation.mutate(providerType);
|
||||
};
|
||||
|
||||
const handleStart = async (providerType: string) => {
|
||||
startMutation.mutate(providerType);
|
||||
};
|
||||
|
||||
const handleDelete = (providerType: string) => {
|
||||
setProviderToDelete(providerType);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (providerToDelete) {
|
||||
deleteMutation.mutate(providerToDelete);
|
||||
setDeleteDialogOpen(false);
|
||||
setProviderToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>TTS Provider</CardTitle>
|
||||
<CardDescription>Choose how Voicebox generates speech</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const installedProviders = providersData?.installed || [];
|
||||
|
||||
// Determine current active provider
|
||||
const currentProvider = activeProvider?.provider;
|
||||
console.log('currentProvider', currentProvider);
|
||||
const selectedProvider = currentProvider as ProviderType;
|
||||
|
||||
const isStarting = startMutation.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>TTS Provider</CardTitle>
|
||||
<CardDescription>Choose how Voicebox generates speech.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="relative">
|
||||
{isStarting && (
|
||||
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-5 w-5" />
|
||||
<span>Starting provider...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<RadioGroup
|
||||
value={selectedProvider}
|
||||
onValueChange={(value) => handleStart(value)}
|
||||
disabled={isStarting}
|
||||
>
|
||||
{/* PyTorch CUDA */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className={`flex items-center space-x-3 flex-1 ${isMacOS() || !installedProviders.includes('pytorch-cuda') ? 'opacity-50' : ''}`}>
|
||||
<RadioGroupItem value="pytorch-cuda" id="cuda" disabled={isMacOS() || isStarting || !installedProviders.includes('pytorch-cuda')} />
|
||||
<Label
|
||||
htmlFor="cuda"
|
||||
className={`flex-1 ${isMacOS() || isStarting || !installedProviders.includes('pytorch-cuda') ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="font-medium">PyTorch CUDA</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
NVIDIA GPU-accelerated provider
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isMacOS() && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">2.4GB</span>
|
||||
<Button size="sm" variant="secondary" disabled>
|
||||
Not Available on macOS
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isMacOS() && !installedProviders.includes('pytorch-cuda') && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">2.4GB</span>
|
||||
<Button
|
||||
onClick={() => handleDownload('pytorch-cuda')}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingProvider === 'pytorch-cuda' || isStarting}
|
||||
className="shrink-0"
|
||||
>
|
||||
{downloadingProvider === 'pytorch-cuda' ? (
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{installedProviders.includes('pytorch-cuda') && (
|
||||
<Button
|
||||
onClick={() => handleDelete('pytorch-cuda')}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isStarting}
|
||||
className="shrink-0"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Uninstall
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PyTorch CPU */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className={`flex items-center space-x-3 flex-1 ${!installedProviders.includes('pytorch-cpu') ? 'opacity-50' : ''}`}>
|
||||
<RadioGroupItem value="pytorch-cpu" id="cpu" disabled={isStarting || !installedProviders.includes('pytorch-cpu')} />
|
||||
<Label
|
||||
htmlFor="cpu"
|
||||
className={`flex-1 ${isStarting || !installedProviders.includes('pytorch-cpu') ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="font-medium">PyTorch CPU</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Works on any system, slower inference
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!installedProviders.includes('pytorch-cpu') && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">242MB</span>
|
||||
<Button
|
||||
onClick={() => handleDownload('pytorch-cpu')}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingProvider === 'pytorch-cpu' || isStarting}
|
||||
className="shrink-0"
|
||||
>
|
||||
{downloadingProvider === 'pytorch-cpu' ? (
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{installedProviders.includes('pytorch-cpu') && (
|
||||
<Button
|
||||
onClick={() => handleDelete('pytorch-cpu')}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isStarting}
|
||||
className="shrink-0"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Uninstall
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MLX bundled (macOS Apple Silicon only) */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className={`flex items-center space-x-3 flex-1 ${!isMacOS() ? 'opacity-50' : ''}`}>
|
||||
<RadioGroupItem value="apple-mlx" id="mlx" disabled={isStarting || !isMacOS()} />
|
||||
<Label
|
||||
htmlFor="mlx"
|
||||
className={`flex-1 ${isStarting || !isMacOS() ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="font-medium">Apple MLX</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isMacOS()
|
||||
? 'Bundled with this version, optimized for Apple Silicon'
|
||||
: 'Only available on Apple Silicon'}
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{!isMacOS() && (
|
||||
<Button size="sm" variant="secondary" disabled>
|
||||
Not Available on {getPlatformName()}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remote */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center space-x-3 flex-1 opacity-50">
|
||||
<RadioGroupItem value="remote" id="remote" disabled />
|
||||
<Label htmlFor="remote" className="flex-1 cursor-not-allowed">
|
||||
<div className="font-medium">Remote Server</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Connect to your own TTS server
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" disabled>
|
||||
Coming Soon
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* OpenAI */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center space-x-3 flex-1 opacity-50">
|
||||
<RadioGroupItem value="openai" id="openai" disabled />
|
||||
<Label htmlFor="openai" className="flex-1 cursor-not-allowed">
|
||||
<div className="font-medium">OpenAI API</div>
|
||||
<div className="text-sm text-muted-foreground">Use OpenAI's TTS API</div>
|
||||
</Label>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" disabled>
|
||||
Coming Soon
|
||||
</Button>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<p className="text-xs text-muted-foreground mt-5">
|
||||
Note: PyTorch and MLX use different versions of the same model. When switching between
|
||||
them, you will need to redownload the model.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Provider</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete {providerToDelete}? This will remove the provider
|
||||
binary from your system. You can download it again later if needed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-destructive text-destructive-foreground"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Loader2, XCircle } from 'lucide-react';
|
||||
import { CancelCircleIcon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
@@ -32,12 +34,12 @@ export function ServerStatus() {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">Checking connection...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
<HugeiconsIcon icon={CancelCircleIcon} size={16} className="h-4 w-4 text-destructive" />
|
||||
<span className="text-sm text-destructive">Connection failed: {error.message}</span>
|
||||
</div>
|
||||
) : health ? (
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { AlertCircle, Download, RefreshCw } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { AlertCircleIcon, Download01Icon, Refresh01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function UpdateStatus() {
|
||||
const platform = usePlatform();
|
||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
getVersion()
|
||||
platform.metadata
|
||||
.getVersion()
|
||||
.then(setCurrentVersion)
|
||||
.catch(() => setCurrentVersion('0.1.0'));
|
||||
}, []);
|
||||
.catch(() => setCurrentVersion('Unknown'));
|
||||
}, [platform]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -34,21 +37,21 @@ export function UpdateStatus() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
|
||||
<HugeiconsIcon icon={Refresh01Icon} size={16} className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
|
||||
Check for Updates
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{status.checking && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
<HugeiconsIcon icon={Refresh01Icon} size={16} className="h-4 w-4 animate-spin" />
|
||||
Checking for updates...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={AlertCircleIcon} size={16} className="h-4 w-4" />
|
||||
{status.error}
|
||||
</div>
|
||||
)}
|
||||
@@ -63,7 +66,7 @@ export function UpdateStatus() {
|
||||
<Badge>New</Badge>
|
||||
</div>
|
||||
<Button onClick={downloadAndInstall} className="w-full" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Download Update
|
||||
</Button>
|
||||
</div>
|
||||
@@ -73,7 +76,7 @@ export function UpdateStatus() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4" />
|
||||
Downloading update...
|
||||
</div>
|
||||
{status.downloadProgress !== undefined && (
|
||||
@@ -107,7 +110,7 @@ export function UpdateStatus() {
|
||||
your convenience.
|
||||
</div>
|
||||
<Button onClick={restartAndInstall} className="w-full" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
<HugeiconsIcon icon={Refresh01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Restart Now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||
import { DataFolders } from '@/components/ServerSettings/DataFolders';
|
||||
import { ProviderSettings } from '@/components/ServerSettings/ProviderSettings';
|
||||
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
||||
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function ServerTab() {
|
||||
const platform = usePlatform();
|
||||
return (
|
||||
<div className="space-y-4 overflow-y-auto flex flex-col">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ConnectionForm />
|
||||
<ServerStatus />
|
||||
</div>
|
||||
{isTauri() && <UpdateStatus />}
|
||||
<ProviderSettings />
|
||||
<DataFolders />
|
||||
{platform.metadata.isTauri && <UpdateStatus />}
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
Created by{' '}
|
||||
<a
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import {
|
||||
Book01Icon,
|
||||
Mic01Icon,
|
||||
PackageIcon,
|
||||
ServerStack01Icon,
|
||||
SpeakerIcon,
|
||||
VolumeHighIcon,
|
||||
} from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||
import { Box, BookOpen, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
@@ -10,12 +19,12 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
|
||||
{ id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' },
|
||||
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
|
||||
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
|
||||
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
|
||||
{ id: 'server', path: '/server', icon: Server, label: 'Server' },
|
||||
{ id: 'main', path: '/', icon: VolumeHighIcon, label: 'Generate' },
|
||||
{ id: 'stories', path: '/stories', icon: Book01Icon, label: 'Stories' },
|
||||
{ id: 'voices', path: '/voices', icon: Mic01Icon, label: 'Voices' },
|
||||
{ id: 'audio', path: '/audio', icon: SpeakerIcon, label: 'Audio' },
|
||||
{ id: 'models', path: '/models', icon: PackageIcon, label: 'Models' },
|
||||
{ id: 'server', path: '/server', icon: ServerStack01Icon, label: 'Server' },
|
||||
];
|
||||
|
||||
export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
@@ -42,9 +51,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
const Icon = tab.icon;
|
||||
// For index route, use exact match; for others, use default matching
|
||||
const isActive =
|
||||
tab.path === '/'
|
||||
? matchRoute({ to: '/', exact: true })
|
||||
: matchRoute({ to: tab.path });
|
||||
tab.path === '/' ? matchRoute({ to: '/' }) : matchRoute({ to: tab.path });
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -58,7 +65,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
title={tab.label}
|
||||
aria-label={tab.label}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<HugeiconsIcon icon={Icon} size={20} className="h-5 w-5" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -75,7 +82,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
isPlayerVisible ? 'mb-[120px]' : 'mb-0',
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-6 w-6 text-accent animate-spin" />
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-6 w-6 text-accent animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { DragDropVerticalIcon, MoreHorizontalIcon, PlayIcon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ProfileAvatar } from '@/components/VoiceProfiles/ProfileAvatar';
|
||||
import type { StoryItemDetail } from '@/lib/api/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
interface StoryChatItemProps {
|
||||
item: StoryItemDetail;
|
||||
@@ -35,10 +35,6 @@ export function StoryChatItem({
|
||||
isDragging,
|
||||
}: StoryChatItemProps) {
|
||||
const seek = useStoryStore((state) => state.seek);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
|
||||
const avatarUrl = `${serverUrl}/profiles/${item.profile_id}/avatar`;
|
||||
|
||||
// Check if this item is currently playing based on timecode
|
||||
const itemStartMs = item.start_time_ms;
|
||||
@@ -74,27 +70,18 @@ export function StoryChatItem({
|
||||
className="shrink-0 cursor-grab active:cursor-grabbing touch-none text-muted-foreground hover:text-foreground transition-colors"
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
<HugeiconsIcon icon={DragDropVerticalIcon} size={20} className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Voice Avatar */}
|
||||
<div className="shrink-0">
|
||||
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
|
||||
{!avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${item.profile_name} avatar`}
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-all duration-200',
|
||||
!isCurrentlyPlaying && 'grayscale'
|
||||
)}
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Mic className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
profileId={item.profile_id}
|
||||
size="lg"
|
||||
grayscale={!isCurrentlyPlaying}
|
||||
alt={`${item.profile_name} avatar`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -119,16 +106,16 @@ export function StoryChatItem({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handlePlay}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={PlayIcon} size={16} className="mr-2 h-4 w-4" />
|
||||
Play from here
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onRemove} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Remove from Story
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Download, Plus } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Download01Icon, Add01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -271,7 +272,7 @@ export function StoryContent() {
|
||||
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Add
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -316,7 +317,7 @@ export function StoryContent() {
|
||||
onClick={handleExportAudio}
|
||||
disabled={exportAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Export Audio
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Plus, BookOpen, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Add01Icon, Book01Icon, MoreHorizontalIcon, PencilIcon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -29,7 +30,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useStories, useCreateStory, useUpdateStory, useDeleteStory } from '@/lib/hooks/useStories';
|
||||
import { useStories, useCreateStory, useUpdateStory, useDeleteStory, useStory } from '@/lib/hooks/useStories';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatDate } from '@/lib/utils/format';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
@@ -38,6 +39,8 @@ export function StoryList() {
|
||||
const { data: stories, isLoading } = useStories();
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||
const { data: currentStory } = useStory(selectedStoryId);
|
||||
const createStory = useCreateStory();
|
||||
const updateStory = useUpdateStory();
|
||||
const deleteStory = useDeleteStory();
|
||||
@@ -54,6 +57,16 @@ export function StoryList() {
|
||||
const [newStoryDescription, setNewStoryDescription] = useState('');
|
||||
const { toast } = useToast();
|
||||
|
||||
// Calculate bottom padding to account for FloatingGenerateBox and StoryTrackEditor
|
||||
const hasTrackEditor = currentStory && currentStory.items.length > 0;
|
||||
const bottomPadding = useMemo(() => {
|
||||
// FloatingGenerateBox height (~100px) + gap (24px)
|
||||
const generateBoxHeight = 124;
|
||||
// Track editor height when visible
|
||||
const editorHeight = hasTrackEditor ? trackEditorHeight + 24 : 0;
|
||||
return generateBoxHeight + editorHeight;
|
||||
}, [hasTrackEditor, trackEditorHeight]);
|
||||
|
||||
const handleCreateStory = () => {
|
||||
if (!newStoryName.trim()) {
|
||||
toast({
|
||||
@@ -177,16 +190,19 @@ export function StoryList() {
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h2 className="text-2xl font-bold">Stories</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
New Story
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Story List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-2"
|
||||
style={{ paddingBottom: `${bottomPadding}px` }}
|
||||
>
|
||||
{storyList.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<HugeiconsIcon icon={Book01Icon} size={48} className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">No stories yet</p>
|
||||
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||
</div>
|
||||
@@ -227,19 +243,19 @@ export function StoryList() {
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={PencilIcon} size={16} className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(story.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import {
|
||||
Copy,
|
||||
GripHorizontal,
|
||||
Minus,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
Scissors,
|
||||
Square,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
Copy01Icon,
|
||||
DragDropHorizontalIcon,
|
||||
RemoveIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
Add01Icon,
|
||||
Scissor01Icon,
|
||||
SquareIcon,
|
||||
Delete01Icon,
|
||||
} from '@hugeicons/core-free-icons';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -723,7 +724,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
onMouseDown={handleResizeStart}
|
||||
aria-label="Resize track editor"
|
||||
>
|
||||
<GripHorizontal className="h-3 w-3 text-muted-foreground/50 group-hover:text-muted-foreground" />
|
||||
<HugeiconsIcon icon={DragDropHorizontalIcon} size={12} className="h-3 w-3 text-muted-foreground/50 group-hover:text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{/* Toolbar */}
|
||||
@@ -737,7 +738,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
onClick={handlePlayPause}
|
||||
title="Play/Pause (Space)"
|
||||
>
|
||||
{isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{isCurrentlyPlaying ? <HugeiconsIcon icon={PauseIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={PlayIcon} size={16} className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -746,7 +747,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
onClick={handleStop}
|
||||
disabled={!isCurrentlyPlaying}
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
<HugeiconsIcon icon={SquareIcon} size={12} className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground tabular-nums ml-2">
|
||||
{formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
|
||||
@@ -763,7 +764,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
onClick={handleSplit}
|
||||
title="Split at playhead (S)"
|
||||
>
|
||||
<Scissors className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Scissor01Icon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -772,7 +773,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
onClick={handleDuplicate}
|
||||
title="Duplicate (Cmd/Ctrl+D)"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Copy01Icon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -781,7 +782,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
onClick={handleDelete}
|
||||
title="Delete (Delete/Backspace)"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -790,10 +791,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Zoom:</span>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
|
||||
<Minus className="h-3 w-3" />
|
||||
<HugeiconsIcon icon={RemoveIcon} size={12} className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
|
||||
<Plus className="h-3 w-3" />
|
||||
<HugeiconsIcon icon={Add01Icon} size={12} className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -837,7 +838,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
type="button"
|
||||
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
|
||||
style={{ width: `${timelineWidth}px` }}
|
||||
onClick={handleTimelineClick}
|
||||
onClick={(e) => handleTimelineClick(e as unknown as React.MouseEvent<HTMLDivElement>)}
|
||||
aria-label="Seek timeline"
|
||||
>
|
||||
{timeMarkers.map((ms) => (
|
||||
@@ -878,7 +879,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-0 cursor-pointer"
|
||||
onClick={handleTimelineClick}
|
||||
onClick={(e) => handleTimelineClick(e as unknown as React.MouseEvent<HTMLDivElement>)}
|
||||
aria-label="Seek timeline"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Mic, Pause, Play, Square } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Mic01Icon, PauseIcon, PlayIcon, SquareIcon } from '@hugeicons/core-free-icons';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { Visualizer } from 'react-sound-visualizer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -95,7 +96,7 @@ export function AudioSampleRecording({
|
||||
size="lg"
|
||||
className="relative z-10 flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-5 w-5" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={20} className="h-5 w-5" />
|
||||
Start Recording
|
||||
</Button>
|
||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||
@@ -122,7 +123,7 @@ export function AudioSampleRecording({
|
||||
onClick={onStop}
|
||||
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={SquareIcon} size={16} className="h-4 w-4" />
|
||||
Stop Recording
|
||||
</Button>
|
||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||
@@ -134,13 +135,13 @@ export function AudioSampleRecording({
|
||||
{file && !isRecording && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mic className="h-5 w-5 text-primary" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={20} className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Recording complete</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={PlayIcon} size={16} className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -149,7 +150,7 @@ export function AudioSampleRecording({
|
||||
disabled={isTranscribing}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Mic01Icon, DeskIcon, PauseIcon, PlayIcon, SquareIcon } from '@hugeicons/core-free-icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
@@ -35,7 +36,7 @@ export function AudioSampleSystem({
|
||||
{!isRecording && !file && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
<HugeiconsIcon icon={DeskIcon} size={20} className="h-5 w-5" />
|
||||
Start Capture
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
@@ -60,7 +61,7 @@ export function AudioSampleSystem({
|
||||
variant="destructive"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={SquareIcon} size={16} className="h-4 w-4" />
|
||||
Stop Capture
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
@@ -72,13 +73,13 @@ export function AudioSampleSystem({
|
||||
{file && !isRecording && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5 text-primary" />
|
||||
<HugeiconsIcon icon={DeskIcon} size={20} className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Capture complete</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={PlayIcon} size={16} className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -87,7 +88,7 @@ export function AudioSampleSystem({
|
||||
disabled={isTranscribing}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Mic, Pause, Play, Upload } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Mic01Icon, PauseIcon, PlayIcon, Upload01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
@@ -89,7 +90,7 @@ export function AudioSampleUpload({
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
<HugeiconsIcon icon={Upload01Icon} size={20} className="h-5 w-5" />
|
||||
Choose File
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
@@ -99,7 +100,7 @@ export function AudioSampleUpload({
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5 text-primary" />
|
||||
<HugeiconsIcon icon={Upload01Icon} size={20} className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">File uploaded</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
@@ -111,7 +112,7 @@ export function AudioSampleUpload({
|
||||
onClick={onPlayPause}
|
||||
disabled={isValidating}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={PlayIcon} size={16} className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -120,7 +121,7 @@ export function AudioSampleUpload({
|
||||
disabled={isTranscribing || isValidating || isDisabled}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Mic01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
interface ProfileAvatarProps {
|
||||
profileId: string;
|
||||
avatarPath?: string | null;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
grayscale?: boolean;
|
||||
className?: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-6 w-6',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-10 w-10',
|
||||
xl: 'h-24 w-24',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 20,
|
||||
xl: 40,
|
||||
};
|
||||
|
||||
const iconClassNames = {
|
||||
sm: 'h-3.5 w-3.5',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5',
|
||||
xl: 'h-10 w-10',
|
||||
};
|
||||
|
||||
export function ProfileAvatar({
|
||||
profileId,
|
||||
avatarPath,
|
||||
size = 'md',
|
||||
grayscale = false,
|
||||
className,
|
||||
alt = 'Profile avatar',
|
||||
}: ProfileAvatarProps) {
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
// If avatarPath is explicitly null or empty string, don't try to load avatar
|
||||
// Otherwise, always try to load (avatarPath might not be available in all contexts)
|
||||
const avatarUrl =
|
||||
avatarPath === null || avatarPath === '' ? null : `${serverUrl}/profiles/${profileId}/avatar`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
sizeClasses[size],
|
||||
'rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{avatarUrl && !avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-all duration-200',
|
||||
grayscale && 'grayscale',
|
||||
)}
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
) : (
|
||||
<HugeiconsIcon
|
||||
icon={Mic01Icon}
|
||||
size={iconSizes[size]}
|
||||
className={cn(iconClassNames[size], 'text-muted-foreground')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { useServerStore } from '@/stores/serverStore';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ProfileAvatar } from '@/components/VoiceProfiles/ProfileAvatar';
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
@@ -24,21 +25,15 @@ interface ProfileCardProps {
|
||||
|
||||
export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
const deleteProfile = useDeleteProfile();
|
||||
const exportProfile = useExportProfile();
|
||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
const isSelected = selectedProfileId === profile.id;
|
||||
|
||||
const avatarUrl = profile.avatar_path
|
||||
? `${serverUrl}/profiles/${profile.id}/avatar`
|
||||
: null;
|
||||
|
||||
const handleSelect = () => {
|
||||
setSelectedProfileId(isSelected ? null : profile.id);
|
||||
};
|
||||
@@ -74,21 +69,13 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
>
|
||||
<CardHeader className="p-3 pb-2">
|
||||
<CardTitle className="flex items-center gap-1.5 text-base font-medium">
|
||||
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{avatarUrl && !avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${profile.name} avatar`}
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-all duration-200',
|
||||
!isSelected && 'grayscale'
|
||||
)}
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Mic className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
profileId={profile.id}
|
||||
avatarPath={profile.avatar_path}
|
||||
size="sm"
|
||||
grayscale={!isSelected}
|
||||
alt={`${profile.name} avatar`}
|
||||
/>
|
||||
<span className="break-words">{profile.name}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -103,13 +90,13 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
</div>
|
||||
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
||||
<CircleButton
|
||||
icon={Download}
|
||||
icon={(props) => <HugeiconsIcon icon={Download01Icon} size={14} {...props} />}
|
||||
onClick={handleExport}
|
||||
disabled={exportProfile.isPending}
|
||||
aria-label="Export profile"
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
icon={(props) => <HugeiconsIcon icon={Edit01Icon} size={14} {...props} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit();
|
||||
@@ -117,7 +104,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
aria-label="Edit profile"
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Trash2}
|
||||
icon={(props) => <HugeiconsIcon icon={Delete01Icon} size={14} {...props} />}
|
||||
onClick={handleDeleteClick}
|
||||
disabled={deleteProfile.isPending}
|
||||
aria-label="Delete profile"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Edit2, Mic, Monitor, Upload, X } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Edit02Icon, Mic01Icon, DeskIcon, Upload01Icon, Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
@@ -43,10 +44,10 @@ import {
|
||||
} from '@/lib/hooks/useProfiles';
|
||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
||||
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
|
||||
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||
@@ -102,6 +103,7 @@ function base64ToFile(base64: string, fileName: string, fileType: string): File
|
||||
}
|
||||
|
||||
export function ProfileForm() {
|
||||
const platform = usePlatform();
|
||||
const open = useUIStore((state) => state.profileDialogOpen);
|
||||
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const editingProfileId = useUIStore((state) => state.editingProfileId);
|
||||
@@ -427,7 +429,8 @@ export function ProfileForm() {
|
||||
} catch (avatarError) {
|
||||
toast({
|
||||
title: 'Avatar upload failed',
|
||||
description: avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
description:
|
||||
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -520,7 +523,8 @@ export function ProfileForm() {
|
||||
} catch (avatarError) {
|
||||
toast({
|
||||
title: 'Avatar upload failed',
|
||||
description: avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
description:
|
||||
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -632,7 +636,7 @@ export function ProfileForm() {
|
||||
setSampleMode('record');
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={12} className="h-3 w-3 mr-1" />
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
@@ -662,19 +666,19 @@ export function ProfileForm() {
|
||||
}}
|
||||
>
|
||||
<TabsList
|
||||
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
<HugeiconsIcon icon={Upload01Icon} size={16} className="h-4 w-4 shrink-0" />
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
</TabsTrigger>
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
<HugeiconsIcon icon={DeskIcon} size={16} className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
</TabsTrigger>
|
||||
)}
|
||||
@@ -724,7 +728,7 @@ export function ProfileForm() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsContent value="system" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -795,7 +799,7 @@ export function ProfileForm() {
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Mic className="h-10 w-10 text-muted-foreground" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={40} className="h-10 w-10 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
@@ -803,7 +807,7 @@ export function ProfileForm() {
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
className="absolute inset-0 rounded-full bg-accent/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<Edit2 className="h-6 w-6 text-accent-foreground" />
|
||||
<HugeiconsIcon icon={Edit02Icon} size={24} className="h-6 w-6 text-accent-foreground" />
|
||||
</button>
|
||||
{(avatarPreview || editingProfile?.avatar_path) && (
|
||||
<button
|
||||
@@ -812,7 +816,7 @@ export function ProfileForm() {
|
||||
disabled={deleteAvatar.isPending}
|
||||
className="absolute bottom-0 right-0 h-6 w-6 rounded-full bg-background/60 backdrop-blur-sm text-muted-foreground flex items-center justify-center hover:bg-background/80 hover:text-foreground transition-colors shadow-sm border border-border/50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={14} className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Mic, Sparkles } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Mic01Icon, SparklesIcon } from '@hugeicons/core-free-icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
@@ -30,12 +31,12 @@ export function ProfileList() {
|
||||
{allProfiles.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={48} className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No voice profiles yet. Create your first profile to get started.
|
||||
</p>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
<HugeiconsIcon icon={SparklesIcon} size={16} className="mr-2 h-4 w-4" />
|
||||
Create Voice
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Plus, Trash2, Play, Edit, Check, X, Volume2, Pause } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { CheckmarkCircle01Icon, Edit01Icon, PauseIcon, PlayIcon, Add01Icon, Delete01Icon, VolumeHighIcon, Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CircleButton } from '@/components/ui/circle-button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles';
|
||||
@@ -95,7 +104,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
||||
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={14} className="h-3.5 w-3.5" /> : <HugeiconsIcon icon={PlayIcon} size={14} className="h-3.5 w-3.5 ml-0.5" />}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
@@ -121,7 +130,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
onClick={handleStop}
|
||||
title="Stop"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={14} className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,10 +149,19 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [editingSampleId, setEditingSampleId] = useState<string | null>(null);
|
||||
const [editedText, setEditedText] = useState<string>('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [sampleToDelete, setSampleToDelete] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = (sampleId: string) => {
|
||||
if (confirm('Are you sure you want to delete this sample?')) {
|
||||
deleteSample.mutate(sampleId);
|
||||
const handleDeleteClick = (sampleId: string) => {
|
||||
setSampleToDelete(sampleId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (sampleToDelete) {
|
||||
deleteSample.mutate(sampleToDelete);
|
||||
setDeleteDialogOpen(false);
|
||||
setSampleToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -192,9 +210,11 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
<div className="space-y-4 pt-4">
|
||||
{samples && samples.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
||||
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<HugeiconsIcon icon={VolumeHighIcon} size={32} className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No samples yet</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">Add your first audio sample to get started</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Add your first audio sample to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -206,14 +226,14 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
key={sample.id}
|
||||
className={cn(
|
||||
'group relative rounded-lg border bg-card transition-all duration-200',
|
||||
isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30'
|
||||
isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30',
|
||||
)}
|
||||
>
|
||||
{isEditing ? (
|
||||
/* Edit Mode */
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||
<Edit className="h-3 w-3" />
|
||||
<HugeiconsIcon icon={Edit01Icon} size={12} className="h-3 w-3" />
|
||||
<span>Editing transcription</span>
|
||||
</div>
|
||||
<Textarea
|
||||
@@ -231,7 +251,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
onClick={handleCancelEdit}
|
||||
disabled={updateSample.isPending}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={16} className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@@ -240,7 +260,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
onClick={() => handleSaveEdit(sample.id)}
|
||||
disabled={updateSample.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
<HugeiconsIcon icon={CheckmarkCircle01Icon} size={16} className="h-4 w-4 mr-1" />
|
||||
{updateSample.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -259,14 +279,14 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
{/* Action Buttons */}
|
||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
icon={(props) => <HugeiconsIcon icon={Edit01Icon} size={14} {...props} />}
|
||||
title="Edit transcription"
|
||||
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Trash2}
|
||||
icon={(props) => <HugeiconsIcon icon={Delete01Icon} size={14} {...props} />}
|
||||
title="Delete sample"
|
||||
onClick={() => handleDelete(sample.id)}
|
||||
onClick={() => handleDeleteClick(sample.id)}
|
||||
disabled={deleteSample.isPending}
|
||||
/>
|
||||
</div>
|
||||
@@ -287,12 +307,52 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="button" variant="outline" className="w-full" onClick={() => setUploadOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||
Add Sample
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center px-2">
|
||||
Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple
|
||||
samples. In a future update samples might be interchangeable and tagged for varying styles
|
||||
of the same voice.
|
||||
</p>
|
||||
|
||||
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Sample</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this audio sample? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setSampleToDelete(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteSample.isPending}
|
||||
>
|
||||
{deleteSample.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Mic, Monitor, Upload } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Mic01Icon, DeskIcon, Upload01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
@@ -27,7 +28,7 @@ import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||
@@ -49,6 +50,7 @@ interface SampleUploadProps {
|
||||
}
|
||||
|
||||
export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) {
|
||||
const platform = usePlatform();
|
||||
const addSample = useAddSample();
|
||||
const transcribe = useTranscription();
|
||||
const { data: profile } = useProfile(profileId);
|
||||
@@ -232,19 +234,19 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record' | 'system')}>
|
||||
<TabsList
|
||||
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
<HugeiconsIcon icon={Upload01Icon} size={16} className="h-4 w-4 shrink-0" />
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
</TabsTrigger>
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
<HugeiconsIcon icon={DeskIcon} size={16} className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
</TabsTrigger>
|
||||
)}
|
||||
@@ -289,7 +291,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsContent value="system" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Edit, MoreHorizontal, Plus, Trash2, Mic } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Edit01Icon, MoreHorizontalIcon, Add01Icon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
|
||||
import { ProfileAvatar } from '@/components/VoiceProfiles/ProfileAvatar';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
@@ -112,7 +114,7 @@ export function VoicesTab() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Voices</h1>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
New Voice
|
||||
</Button>
|
||||
</div>
|
||||
@@ -184,9 +186,12 @@ function VoiceRow({
|
||||
<TableRow className="cursor-pointer" onClick={onEdit}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
profileId={profile.id}
|
||||
avatarPath={profile.avatar_path}
|
||||
size="md"
|
||||
alt={`${profile.name} avatar`}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{profile.name}</div>
|
||||
{profile.description && (
|
||||
@@ -214,16 +219,16 @@ function VoiceRow({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
<HugeiconsIcon icon={Edit01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { CheckmarkCircle01Icon } from '@hugeicons/core-free-icons';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface CheckboxProps {
|
||||
@@ -34,7 +35,7 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{checked && <Check className="h-3 w-3 text-accent-foreground" />}
|
||||
{checked && <HugeiconsIcon icon={CheckmarkCircle01Icon} size={12} className="h-3 w-3 text-accent-foreground" />}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -6,10 +6,11 @@ export interface CircleButtonProps extends React.ButtonHTMLAttributes<HTMLButton
|
||||
}
|
||||
|
||||
const CircleButton = React.forwardRef<HTMLButtonElement, CircleButtonProps>(
|
||||
({ className, icon: Icon, ...props }, ref) => {
|
||||
({ className, icon: Icon, type = 'button', ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
'hover:bg-muted transition-colors',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
@@ -42,7 +43,7 @@ const DialogContent = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={16} className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { MoreHorizontalIcon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
@@ -26,7 +27,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<MoreHorizontal className="ml-auto h-4 w-4" />
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
@@ -73,7 +74,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
@@ -97,7 +98,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -119,7 +120,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<MoreHorizontal className="h-2 w-2 fill-current" />
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} size={8} className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -154,7 +155,9 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />;
|
||||
return (
|
||||
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronDown, Check } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { ArrowDown01Icon, CheckmarkCircle01Icon } from '@hugeicons/core-free-icons';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -36,7 +37,7 @@ const MultiSelectCheckboxItem = React.forwardRef<
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={CheckmarkCircle01Icon} size={16} className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -78,7 +79,7 @@ export function MultiSelect({
|
||||
)}
|
||||
>
|
||||
<span className="line-clamp-1">{displayText}</span>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} size={16} className="h-4 w-4 opacity-50" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { CircleIcon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return <RadioGroupPrimitive.Root className={cn('grid gap-2', className)} {...props} ref={ref} />;
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'aspect-square h-4 w-4 rounded-full border border-accent text-accent ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<HugeiconsIcon
|
||||
icon={CircleIcon}
|
||||
size={10}
|
||||
className="h-2.5 w-2.5 fill-current text-current"
|
||||
/>
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { CheckmarkCircle01Icon, ArrowDown01Icon, ArrowUp01Icon } from '@hugeicons/core-free-icons';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
@@ -23,7 +24,7 @@ const SelectTrigger = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} size={16} className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
@@ -38,7 +39,7 @@ const SelectScrollUpButton = React.forwardRef<
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={ArrowUp01Icon} size={16} className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
@@ -52,7 +53,7 @@ const SelectScrollDownButton = React.forwardRef<
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} size={16} className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
@@ -115,7 +116,7 @@ const SelectItem = React.forwardRef<
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={CheckmarkCircle01Icon} size={16} className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
@@ -79,7 +80,7 @@ const ToastClose = React.forwardRef<
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={16} className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Download01Icon, Refresh01Icon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ToastAction } from '@/components/ui/toast';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import type { UpdateStatus } from '@/platform/types';
|
||||
|
||||
// Re-export UpdateStatus for backwards compatibility
|
||||
export type { UpdateStatus };
|
||||
|
||||
interface UseAutoUpdaterOptions {
|
||||
checkOnMount?: boolean;
|
||||
showToast?: boolean;
|
||||
}
|
||||
|
||||
export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) {
|
||||
// Support both old boolean API and new options object
|
||||
const { checkOnMount, showToast } =
|
||||
typeof options === 'boolean'
|
||||
? { checkOnMount: options, showToast: false }
|
||||
: { checkOnMount: options.checkOnMount ?? false, showToast: options.showToast ?? false };
|
||||
|
||||
const platform = usePlatform();
|
||||
const { toast } = useToast();
|
||||
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||
const hasCheckedRef = useRef(false);
|
||||
const toastIdRef = useRef<string | null>(null);
|
||||
const toastUpdateRef = useRef<
|
||||
| ((props: {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
duration?: number;
|
||||
variant?: 'default' | 'destructive';
|
||||
open?: boolean;
|
||||
action?: React.ReactElement<typeof ToastAction>;
|
||||
}) => void)
|
||||
| null
|
||||
>(null);
|
||||
|
||||
// Subscribe to updater status changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = platform.updater.subscribe((newStatus) => {
|
||||
setStatus(newStatus);
|
||||
});
|
||||
return unsubscribe;
|
||||
// Empty dependency array - platform is stable from context
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.subscribe]);
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
await platform.updater.checkForUpdates();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.checkForUpdates]);
|
||||
|
||||
const downloadAndInstall = useCallback(async () => {
|
||||
await platform.updater.downloadAndInstall();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.downloadAndInstall]);
|
||||
|
||||
const restartAndInstall = useCallback(async () => {
|
||||
await platform.updater.restartAndInstall();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.restartAndInstall]);
|
||||
|
||||
// Check for updates on mount
|
||||
useEffect(() => {
|
||||
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||
hasCheckedRef.current = true;
|
||||
checkForUpdates().catch((error) => {
|
||||
console.error('Auto update check failed:', error);
|
||||
});
|
||||
}
|
||||
// Empty dependency array - only run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauri, checkOnMount, checkForUpdates]);
|
||||
|
||||
// Show toast when update is available
|
||||
useEffect(() => {
|
||||
if (
|
||||
!showToast ||
|
||||
!status.available ||
|
||||
status.downloading ||
|
||||
status.readyToInstall ||
|
||||
toastIdRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleUpdateNow = async () => {
|
||||
await downloadAndInstall();
|
||||
};
|
||||
|
||||
const toastResult = toast({
|
||||
title: 'Update Available',
|
||||
description: `Version ${status.version} is ready to download.`,
|
||||
duration: Infinity,
|
||||
action: (
|
||||
<ToastAction altText="Update now" onClick={handleUpdateNow}>
|
||||
Update Now
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
|
||||
toastIdRef.current = toastResult.id;
|
||||
// Type assertion needed because update function has broader type than our ref
|
||||
toastUpdateRef.current = toastResult.update as typeof toastUpdateRef.current;
|
||||
}, [
|
||||
showToast,
|
||||
status.available,
|
||||
status.downloading,
|
||||
status.readyToInstall,
|
||||
status.version,
|
||||
downloadAndInstall,
|
||||
toast,
|
||||
]);
|
||||
|
||||
// Update toast when downloading
|
||||
useEffect(() => {
|
||||
if (!showToast || !status.downloading || !toastIdRef.current || !toastUpdateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progressPercent = status.downloadProgress || 0;
|
||||
const progressText =
|
||||
status.downloadedBytes !== undefined &&
|
||||
status.totalBytes !== undefined &&
|
||||
status.totalBytes > 0
|
||||
? `${(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB / ${(status.totalBytes / 1024 / 1024).toFixed(1)} MB`
|
||||
: '';
|
||||
|
||||
toastUpdateRef.current({
|
||||
title: (
|
||||
<div className="flex items-center gap-2">
|
||||
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 animate-pulse" />
|
||||
<span>Downloading Update</span>
|
||||
</div>
|
||||
),
|
||||
description: (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Version {status.version}</div>
|
||||
{progressPercent > 0 && (
|
||||
<>
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
{progressText && <div className="text-xs text-muted-foreground">{progressText}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
duration: Infinity,
|
||||
});
|
||||
}, [
|
||||
showToast,
|
||||
status.downloading,
|
||||
status.downloadProgress,
|
||||
status.downloadedBytes,
|
||||
status.totalBytes,
|
||||
status.version,
|
||||
]);
|
||||
|
||||
// Update toast when ready to install
|
||||
useEffect(() => {
|
||||
if (!showToast || !status.readyToInstall || !toastIdRef.current || !toastUpdateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleRestartNow = async () => {
|
||||
await restartAndInstall();
|
||||
};
|
||||
|
||||
toastUpdateRef.current({
|
||||
title: 'Update Ready',
|
||||
description: `Version ${status.version} has been downloaded and is ready to install.`,
|
||||
duration: Infinity,
|
||||
action: (
|
||||
<ToastAction altText="Restart now" onClick={handleRestartNow}>
|
||||
<HugeiconsIcon icon={Refresh01Icon} size={12} className="h-3 w-3 mr-1" />
|
||||
Restart Now
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
}, [showToast, status.readyToInstall, status.version, restartAndInstall]);
|
||||
|
||||
// Handle errors in toast
|
||||
useEffect(() => {
|
||||
if (!showToast || !status.error || !toastIdRef.current || !toastUpdateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
toastUpdateRef.current({
|
||||
title: 'Update Failed',
|
||||
description: status.error,
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
toastIdRef.current = null;
|
||||
toastUpdateRef.current = null;
|
||||
}, 5000);
|
||||
}, [showToast, status.error]);
|
||||
|
||||
return {
|
||||
status,
|
||||
checkForUpdates,
|
||||
downloadAndInstall,
|
||||
restartAndInstall,
|
||||
};
|
||||
}
|
||||
+127
-31
@@ -1,29 +1,30 @@
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import type { LanguageCode } from '@/lib/constants/languages';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import type {
|
||||
VoiceProfileCreate,
|
||||
VoiceProfileResponse,
|
||||
ProfileSampleResponse,
|
||||
ActiveTasksResponse,
|
||||
FolderPathsResponse,
|
||||
GenerationRequest,
|
||||
GenerationResponse,
|
||||
HistoryQuery,
|
||||
HistoryListResponse,
|
||||
HistoryResponse,
|
||||
TranscriptionResponse,
|
||||
HealthResponse,
|
||||
ModelStatusListResponse,
|
||||
HistoryListResponse,
|
||||
HistoryQuery,
|
||||
HistoryResponse,
|
||||
ModelDownloadRequest,
|
||||
ActiveTasksResponse,
|
||||
ModelStatusListResponse,
|
||||
ProfileSampleResponse,
|
||||
StoryCreate,
|
||||
StoryResponse,
|
||||
StoryDetailResponse,
|
||||
StoryItemBatchUpdate,
|
||||
StoryItemCreate,
|
||||
StoryItemDetail,
|
||||
StoryItemBatchUpdate,
|
||||
StoryItemReorder,
|
||||
StoryItemMove,
|
||||
StoryItemTrim,
|
||||
StoryItemReorder,
|
||||
StoryItemSplit,
|
||||
StoryItemTrim,
|
||||
StoryResponse,
|
||||
TranscriptionResponse,
|
||||
VoiceProfileCreate,
|
||||
VoiceProfileResponse,
|
||||
} from './types';
|
||||
|
||||
class ApiClient {
|
||||
@@ -57,6 +58,11 @@ class ApiClient {
|
||||
return this.request<HealthResponse>('/health');
|
||||
}
|
||||
|
||||
// System
|
||||
async getSystemFolders(): Promise<FolderPathsResponse> {
|
||||
return this.request<FolderPathsResponse>('/system/folders');
|
||||
}
|
||||
|
||||
// Profiles
|
||||
async createProfile(data: VoiceProfileCreate): Promise<VoiceProfileResponse> {
|
||||
return this.request<VoiceProfileResponse>('/profiles', {
|
||||
@@ -199,6 +205,77 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Providers
|
||||
async listProviders(): Promise<{
|
||||
providers: Array<{
|
||||
type: string;
|
||||
name: string;
|
||||
installed: boolean;
|
||||
size_mb: number | null;
|
||||
}>;
|
||||
installed: string[];
|
||||
}> {
|
||||
return this.request('/providers');
|
||||
}
|
||||
|
||||
async getActiveProvider(): Promise<{
|
||||
provider: string;
|
||||
health: {
|
||||
status: string;
|
||||
provider: string;
|
||||
version: string | null;
|
||||
model: string | null;
|
||||
device: string | null;
|
||||
};
|
||||
status: {
|
||||
model_loaded: boolean;
|
||||
model_size: string | null;
|
||||
available_sizes: string[];
|
||||
gpu_available: boolean | null;
|
||||
vram_used_mb: number | null;
|
||||
};
|
||||
}> {
|
||||
return this.request('/providers/active');
|
||||
}
|
||||
|
||||
async startProvider(providerType: string): Promise<{
|
||||
message: string;
|
||||
provider: {
|
||||
status: string;
|
||||
provider: string;
|
||||
version: string | null;
|
||||
model: string | null;
|
||||
device: string | null;
|
||||
};
|
||||
}> {
|
||||
return this.request('/providers/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider_type: providerType }),
|
||||
});
|
||||
}
|
||||
|
||||
async stopProvider(): Promise<{ message: string }> {
|
||||
return this.request('/providers/stop', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async downloadProvider(providerType: string): Promise<{
|
||||
message: string;
|
||||
provider_type: string;
|
||||
}> {
|
||||
return this.request('/providers/download', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider_type: providerType }),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProvider(providerType: string): Promise<{ message: string }> {
|
||||
return this.request(`/providers/${providerType}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// History
|
||||
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
@@ -251,7 +328,15 @@ class ApiClient {
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async importGeneration(file: File): Promise<{ id: string; profile_id: string; profile_name: string; text: string; message: string }> {
|
||||
async importGeneration(
|
||||
file: File,
|
||||
): Promise<{
|
||||
id: string;
|
||||
profile_id: string;
|
||||
profile_name: string;
|
||||
text: string;
|
||||
message: string;
|
||||
}> {
|
||||
const url = `${this.getBaseUrl()}/history/import`;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
@@ -310,10 +395,18 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
|
||||
return this.request<{ message: string }>('/models/download', {
|
||||
console.log(
|
||||
'[API] triggerModelDownload called for:',
|
||||
modelName,
|
||||
'at',
|
||||
new Date().toISOString(),
|
||||
);
|
||||
const result = await this.request<{ message: string }>('/models/download', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
||||
});
|
||||
console.log('[API] triggerModelDownload response:', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteModel(modelName: string): Promise<{ message: string }> {
|
||||
@@ -340,10 +433,7 @@ class ApiClient {
|
||||
return this.request('/channels');
|
||||
}
|
||||
|
||||
async createChannel(data: {
|
||||
name: string;
|
||||
device_ids: string[];
|
||||
}): Promise<{
|
||||
async createChannel(data: { name: string; device_ids: string[] }): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
@@ -385,10 +475,7 @@ class ApiClient {
|
||||
return this.request(`/channels/${channelId}/voices`);
|
||||
}
|
||||
|
||||
async setChannelVoices(
|
||||
channelId: string,
|
||||
profileIds: string[],
|
||||
): Promise<{ message: string }> {
|
||||
async setChannelVoices(channelId: string, profileIds: string[]): Promise<{ message: string }> {
|
||||
return this.request(`/channels/${channelId}/voices`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ profile_ids: profileIds }),
|
||||
@@ -399,10 +486,7 @@ class ApiClient {
|
||||
return this.request(`/profiles/${profileId}/channels`);
|
||||
}
|
||||
|
||||
async setProfileChannels(
|
||||
profileId: string,
|
||||
channelIds: string[],
|
||||
): Promise<{ message: string }> {
|
||||
async setProfileChannels(profileId: string, channelIds: string[]): Promise<{ message: string }> {
|
||||
return this.request(`/profiles/${profileId}/channels`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ channel_ids: channelIds }),
|
||||
@@ -465,21 +549,33 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async moveStoryItem(storyId: string, itemId: string, data: StoryItemMove): Promise<StoryItemDetail> {
|
||||
async moveStoryItem(
|
||||
storyId: string,
|
||||
itemId: string,
|
||||
data: StoryItemMove,
|
||||
): Promise<StoryItemDetail> {
|
||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async trimStoryItem(storyId: string, itemId: string, data: StoryItemTrim): Promise<StoryItemDetail> {
|
||||
async trimStoryItem(
|
||||
storyId: string,
|
||||
itemId: string,
|
||||
data: StoryItemTrim,
|
||||
): Promise<StoryItemDetail> {
|
||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/trim`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async splitStoryItem(storyId: string, itemId: string, data: StoryItemSplit): Promise<StoryItemDetail[]> {
|
||||
async splitStoryItem(
|
||||
storyId: string,
|
||||
itemId: string,
|
||||
data: StoryItemSplit,
|
||||
): Promise<StoryItemDetail[]> {
|
||||
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/${itemId}/split`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ModelStatus = {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
downloaded: boolean;
|
||||
downloading?: boolean; // True if download is in progress
|
||||
size_mb?: number | null;
|
||||
loaded?: boolean;
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface GenerationRequest {
|
||||
language: LanguageCode;
|
||||
seed?: number;
|
||||
model_size?: '1.7B' | '0.6B';
|
||||
instruct?: string;
|
||||
}
|
||||
|
||||
export interface GenerationResponse {
|
||||
@@ -96,6 +97,7 @@ export interface ModelStatus {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
downloaded: boolean;
|
||||
downloading: boolean; // True if download is in progress
|
||||
size_mb?: number;
|
||||
loaded: boolean;
|
||||
}
|
||||
@@ -126,6 +128,12 @@ export interface ActiveTasksResponse {
|
||||
generations: ActiveGenerationTask[];
|
||||
}
|
||||
|
||||
export interface FolderPathsResponse {
|
||||
data_dir: string;
|
||||
models_dir: string;
|
||||
providers_dir: string;
|
||||
}
|
||||
|
||||
export interface StoryCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { convertToWav } from '@/lib/utils/audio';
|
||||
|
||||
interface UseAudioRecordingOptions {
|
||||
@@ -11,6 +11,7 @@ export function useAudioRecording({
|
||||
maxDurationSeconds = 29,
|
||||
onRecordingComplete,
|
||||
}: UseAudioRecordingOptions = {}) {
|
||||
const platform = usePlatform();
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -40,15 +41,14 @@ export function useAudioRecording({
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
const isTauriEnv = isTauri();
|
||||
console.error('MediaDevices check:', {
|
||||
hasNavigator: typeof navigator !== 'undefined',
|
||||
hasMediaDevices: !!navigator?.mediaDevices,
|
||||
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
|
||||
isTauri: isTauriEnv,
|
||||
isTauri: platform.metadata.isTauri,
|
||||
});
|
||||
|
||||
const errorMsg = isTauriEnv
|
||||
const errorMsg = platform.metadata.isTauri
|
||||
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
|
||||
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
|
||||
setError(errorMsg);
|
||||
|
||||
@@ -91,11 +91,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||
instruct: data.instruct || undefined,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Generation complete!',
|
||||
description: `Audio generated (${result.duration.toFixed(2)}s)`,
|
||||
});
|
||||
|
||||
const audioUrl = apiClient.getAudioUrl(result.id);
|
||||
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { HistoryQuery } from '@/lib/api/types';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function useHistory(query?: HistoryQuery) {
|
||||
return useQuery({
|
||||
@@ -30,116 +30,52 @@ export function useDeleteGeneration() {
|
||||
}
|
||||
|
||||
export function useExportGeneration() {
|
||||
const platform = usePlatform();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||
const blob = await apiClient.exportGeneration(generationId);
|
||||
|
||||
|
||||
// Create safe filename from text
|
||||
const safeText = text.substring(0, 30).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const safeText = text
|
||||
.substring(0, 30)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `generation-${safeText}.voicebox.zip`;
|
||||
|
||||
if (isTauri()) {
|
||||
// Use Tauri's native save dialog
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const filePath = await save({
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{
|
||||
name: 'Voicebox Generation',
|
||||
extensions: ['voicebox.zip', 'zip'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
// Write file using Tauri's filesystem API
|
||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
||||
// Fall back to browser download if Tauri dialog fails
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} else {
|
||||
// Browser: trigger download
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
name: 'Voicebox Generation',
|
||||
extensions: ['zip'],
|
||||
},
|
||||
]);
|
||||
|
||||
return blob;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useExportGenerationAudio() {
|
||||
const platform = usePlatform();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||
const blob = await apiClient.exportGenerationAudio(generationId);
|
||||
|
||||
|
||||
// Create safe filename from text
|
||||
const safeText = text.substring(0, 30).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const safeText = text
|
||||
.substring(0, 30)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `${safeText}.wav`;
|
||||
|
||||
if (isTauri()) {
|
||||
// Use Tauri's native save dialog
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const filePath = await save({
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{
|
||||
name: 'Audio File',
|
||||
extensions: ['wav'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
// Write file using Tauri's filesystem API
|
||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
||||
// Fall back to browser download if Tauri dialog fails
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} else {
|
||||
// Browser: trigger download
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
name: 'Audio File',
|
||||
extensions: ['wav'],
|
||||
},
|
||||
]);
|
||||
|
||||
return blob;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { CancelCircleIcon, CheckmarkCircle02Icon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import type { ModelProgress } from '@/lib/api/types';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
interface UseModelDownloadToastOptions {
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
enabled?: boolean;
|
||||
onComplete?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,47 +23,64 @@ export function useModelDownloadToast({
|
||||
modelName,
|
||||
displayName,
|
||||
enabled = false,
|
||||
onComplete,
|
||||
onError,
|
||||
}: UseModelDownloadToastOptions) {
|
||||
const { toast } = useToast();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const toastIdRef = useRef<string | null>(null);
|
||||
const toastUpdateRef = useRef<
|
||||
((props: {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
duration?: number;
|
||||
variant?: 'default' | 'destructive';
|
||||
open?: boolean;
|
||||
}) => void) | null
|
||||
>(null);
|
||||
// biome-ignore lint: Using any for toast update ref to handle complex toast types
|
||||
const toastUpdateRef = useRef<any>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
const formatBytes = useCallback((bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[useModelDownloadToast] useEffect triggered', {
|
||||
enabled,
|
||||
serverUrl,
|
||||
modelName,
|
||||
displayName,
|
||||
});
|
||||
|
||||
if (!enabled || !serverUrl || !modelName) {
|
||||
console.log('[useModelDownloadToast] Not enabled, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[useModelDownloadToast] Creating toast and EventSource for:', modelName);
|
||||
|
||||
// Create initial toast
|
||||
const toastResult = toast({
|
||||
title: displayName,
|
||||
description: 'Starting download...',
|
||||
description: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||
<span>Connecting to download...</span>
|
||||
</div>
|
||||
),
|
||||
duration: Infinity, // Don't auto-dismiss, we'll handle it manually
|
||||
});
|
||||
toastIdRef.current = toastResult.id;
|
||||
toastUpdateRef.current = toastResult.update;
|
||||
|
||||
// Subscribe to progress updates via Server-Sent Events
|
||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||
const eventSourceUrl = `${serverUrl}/models/progress/${modelName}`;
|
||||
console.log('[useModelDownloadToast] Creating EventSource to:', eventSourceUrl);
|
||||
const eventSource = new EventSource(eventSourceUrl);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('[useModelDownloadToast] EventSource connection opened for:', modelName);
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
console.log('[useModelDownloadToast] Received SSE message:', event.data);
|
||||
try {
|
||||
const progress = JSON.parse(event.data) as ModelProgress;
|
||||
|
||||
@@ -77,19 +98,35 @@ export function useModelDownloadToast({
|
||||
|
||||
switch (progress.status) {
|
||||
case 'complete':
|
||||
statusIcon = <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
statusIcon = (
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle02Icon}
|
||||
size={16}
|
||||
className="h-4 w-4 text-green-500"
|
||||
/>
|
||||
);
|
||||
statusText = 'Download complete';
|
||||
break;
|
||||
case 'error':
|
||||
statusIcon = <XCircle className="h-4 w-4 text-destructive" />;
|
||||
statusIcon = (
|
||||
<HugeiconsIcon
|
||||
icon={CancelCircleIcon}
|
||||
size={16}
|
||||
className="h-4 w-4 text-destructive"
|
||||
/>
|
||||
);
|
||||
statusText = `Error: ${progress.error || 'Unknown error'}`;
|
||||
break;
|
||||
case 'downloading':
|
||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||
statusText = progress.filename ? `Downloading ${progress.filename}...` : 'Downloading...';
|
||||
statusIcon = (
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||
);
|
||||
statusText = progress.filename || 'Downloading...';
|
||||
break;
|
||||
case 'extracting':
|
||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||
statusIcon = (
|
||||
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||
);
|
||||
statusText = 'Extracting...';
|
||||
break;
|
||||
}
|
||||
@@ -117,21 +154,44 @@ export function useModelDownloadToast({
|
||||
});
|
||||
|
||||
// Close connection and dismiss toast on completion or error
|
||||
if (progress.status === 'complete' || progress.status === 'error') {
|
||||
// Also treat progress >= 100% as complete
|
||||
const isComplete = progress.status === 'complete' || progress.progress >= 100;
|
||||
const isError = progress.status === 'error';
|
||||
|
||||
if (isComplete || isError) {
|
||||
console.log('[useModelDownloadToast] Download finished:', {
|
||||
isComplete,
|
||||
isError,
|
||||
progress: progress.progress,
|
||||
});
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
// Auto-dismiss on completion after delay
|
||||
if (progress.status === 'complete') {
|
||||
setTimeout(() => {
|
||||
if (toastIdRef.current && toastUpdateRef.current) {
|
||||
toastUpdateRef.current({
|
||||
open: false,
|
||||
});
|
||||
toastIdRef.current = null;
|
||||
toastUpdateRef.current = null;
|
||||
}
|
||||
}, 5000);
|
||||
// Update toast to show completion state before callbacks
|
||||
if (isComplete && toastUpdateRef.current) {
|
||||
toastUpdateRef.current({
|
||||
title: (
|
||||
<div className="flex items-center gap-2">
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle02Icon}
|
||||
size={16}
|
||||
className="h-4 w-4 text-green-500"
|
||||
/>
|
||||
<span>{displayName}</span>
|
||||
</div>
|
||||
),
|
||||
description: 'Download complete',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
// Call callbacks
|
||||
if (isComplete && onComplete) {
|
||||
console.log('[useModelDownloadToast] Download complete, calling onComplete callback');
|
||||
onComplete();
|
||||
} else if (isError && onError) {
|
||||
console.log('[useModelDownloadToast] Download error, calling onError callback');
|
||||
onError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +201,8 @@ export function useModelDownloadToast({
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
console.error('[useModelDownloadToast] SSE error for:', modelName, error);
|
||||
console.log('[useModelDownloadToast] EventSource readyState:', eventSource.readyState);
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
@@ -162,15 +223,16 @@ export function useModelDownloadToast({
|
||||
|
||||
// Cleanup on unmount or when disabled
|
||||
return () => {
|
||||
console.log('[useModelDownloadToast] Cleanup - closing EventSource for:', modelName);
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
// Note: We don't dismiss the toast here as it might still be showing completion state
|
||||
};
|
||||
}, [enabled, serverUrl, modelName, displayName, toast]);
|
||||
}, [enabled, serverUrl, modelName, displayName, toast, formatBytes, onComplete, onError]);
|
||||
|
||||
return {
|
||||
isTracking: enabled && eventSourceRef.current !== null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { VoiceProfileCreate } from '@/lib/api/types';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function useProfiles() {
|
||||
return useQuery({
|
||||
@@ -117,59 +117,24 @@ export function useUpdateSample() {
|
||||
}
|
||||
|
||||
export function useExportProfile() {
|
||||
const platform = usePlatform();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (profileId: string) => {
|
||||
const blob = await apiClient.exportProfile(profileId);
|
||||
|
||||
|
||||
// Get profile name for filename
|
||||
const profile = await apiClient.getProfile(profileId);
|
||||
const safeName = profile.name.replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const filename = `profile-${safeName}.voicebox.zip`;
|
||||
|
||||
if (isTauri()) {
|
||||
// Use Tauri's native save dialog
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const filePath = await save({
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{
|
||||
name: 'Voicebox Profile',
|
||||
extensions: ['voicebox.zip', 'zip'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
// Write file using Tauri's filesystem API
|
||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
||||
// Fall back to browser download if Tauri dialog fails
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} else {
|
||||
// Browser: trigger download
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
name: 'Voicebox Profile',
|
||||
extensions: ['zip'],
|
||||
},
|
||||
]);
|
||||
|
||||
return blob;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function useStories() {
|
||||
return useQuery({
|
||||
@@ -158,6 +158,8 @@ export function useDuplicateStoryItem() {
|
||||
}
|
||||
|
||||
export function useExportStoryAudio() {
|
||||
const platform = usePlatform();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ storyId, storyName }: { storyId: string; storyName: string }) => {
|
||||
const blob = await apiClient.exportStoryAudio(storyId);
|
||||
@@ -166,49 +168,12 @@ export function useExportStoryAudio() {
|
||||
const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const filename = `${safeName || 'story'}.wav`;
|
||||
|
||||
if (isTauri()) {
|
||||
// Use Tauri's native save dialog
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const filePath = await save({
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{
|
||||
name: 'Audio File',
|
||||
extensions: ['wav'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
// Write file using Tauri's filesystem API
|
||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
||||
// Fall back to browser download if Tauri dialog fails
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} else {
|
||||
// Browser: trigger download
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
name: 'Audio File',
|
||||
extensions: ['wav'],
|
||||
},
|
||||
]);
|
||||
|
||||
return blob;
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
interface UseSystemAudioCaptureOptions {
|
||||
maxDurationSeconds?: number;
|
||||
@@ -15,6 +14,7 @@ export function useSystemAudioCapture({
|
||||
maxDurationSeconds = 29,
|
||||
onRecordingComplete,
|
||||
}: UseSystemAudioCaptureOptions = {}) {
|
||||
const platform = usePlatform();
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -26,22 +26,12 @@ export function useSystemAudioCapture({
|
||||
|
||||
// Check if system audio capture is supported
|
||||
useEffect(() => {
|
||||
if (!isTauri()) {
|
||||
setIsSupported(false);
|
||||
return;
|
||||
}
|
||||
|
||||
invoke<boolean>('is_system_audio_supported')
|
||||
.then((supported) => {
|
||||
setIsSupported(supported);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsSupported(false);
|
||||
});
|
||||
}, []);
|
||||
const supported = platform.audio.isSystemAudioSupported();
|
||||
setIsSupported(supported);
|
||||
}, [platform]);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!isTauri()) {
|
||||
if (!platform.metadata.isTauri) {
|
||||
const errorMsg = 'System audio capture is only available in the desktop app.';
|
||||
setError(errorMsg);
|
||||
return;
|
||||
@@ -58,9 +48,7 @@ export function useSystemAudioCapture({
|
||||
setDuration(0);
|
||||
|
||||
// Start native capture
|
||||
await invoke('start_system_audio_capture', {
|
||||
maxDurationSecs: maxDurationSeconds,
|
||||
});
|
||||
await platform.audio.startSystemAudioCapture(maxDurationSeconds);
|
||||
|
||||
setIsRecording(true);
|
||||
isRecordingRef.current = true;
|
||||
@@ -86,10 +74,10 @@ export function useSystemAudioCapture({
|
||||
setError(errorMessage);
|
||||
setIsRecording(false);
|
||||
}
|
||||
}, [maxDurationSeconds, isSupported]);
|
||||
}, [maxDurationSeconds, isSupported, platform]);
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
if (!isRecording || !isTauri()) {
|
||||
if (!isRecording || !platform.metadata.isTauri) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,17 +90,9 @@ export function useSystemAudioCapture({
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// Stop capture and get base64 WAV data
|
||||
const base64Data = await invoke<string>('stop_system_audio_capture');
|
||||
// Stop capture and get Blob
|
||||
const blob = await platform.audio.stopSystemAudioCapture();
|
||||
|
||||
// Convert base64 to Blob
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([bytes], { type: 'audio/wav' });
|
||||
// Pass the actual recorded duration
|
||||
const recordedDuration = startTimeRef.current
|
||||
? (Date.now() - startTimeRef.current) / 1000
|
||||
@@ -125,7 +105,7 @@ export function useSystemAudioCapture({
|
||||
: 'Failed to stop system audio capture.';
|
||||
setError(errorMessage);
|
||||
}
|
||||
}, [isRecording, onRecordingComplete]);
|
||||
}, [isRecording, onRecordingComplete, platform]);
|
||||
|
||||
// Store stopRecording in ref for use in timer
|
||||
useEffect(() => {
|
||||
@@ -155,15 +135,15 @@ export function useSystemAudioCapture({
|
||||
timerRef.current = null;
|
||||
}
|
||||
// Cancel recording on unmount if still recording
|
||||
if (isRecordingRef.current && isTauri()) {
|
||||
if (isRecordingRef.current && platform.metadata.isTauri) {
|
||||
// Call stop directly without the callback to avoid stale closure
|
||||
invoke('stop_system_audio_capture').catch((err) => {
|
||||
platform.audio.stopSystemAudioCapture().catch((err) => {
|
||||
console.error('Error stopping audio capture on unmount:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on unmount
|
||||
}, []);
|
||||
}, [platform]);
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Tauri integration utilities
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, emit } from '@tauri-apps/api/event';
|
||||
|
||||
/**
|
||||
* Check if running in Tauri environment
|
||||
*/
|
||||
export function isTauri(): boolean {
|
||||
return '__TAURI_INTERNALS__' in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running on macOS
|
||||
*/
|
||||
export function isMacOS(): boolean {
|
||||
return navigator.platform.toLowerCase().includes('mac');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the bundled Python server (Tauri only)
|
||||
*/
|
||||
export async function startServer(remote = false): Promise<string> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri environment');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invoke<string>('start_server', { remote });
|
||||
console.log('Server started:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the bundled Python server (Tauri only)
|
||||
*/
|
||||
export async function stopServer(): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri environment');
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('stop_server');
|
||||
console.log('Server stopped');
|
||||
} catch (error) {
|
||||
console.error('Failed to stop server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the server should keep running when the app closes (Tauri only)
|
||||
*/
|
||||
export async function setKeepServerRunning(keepRunning: boolean): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('set_keep_server_running', { keepRunning });
|
||||
} catch (error) {
|
||||
console.error('Failed to set keep server running setting:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup window close handler to check setting and stop server if needed
|
||||
*/
|
||||
export async function setupWindowCloseHandler(): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Listen for window close request from Rust
|
||||
await listen<null>('window-close-requested', async () => {
|
||||
// Import store here to avoid circular dependency
|
||||
const { useServerStore } = await import('@/stores/serverStore');
|
||||
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
||||
|
||||
// Check if server was started by this app instance
|
||||
// In dev mode, serverStartedByApp will be false, so we won't try to stop a separately-run server
|
||||
// We need to access the module-level variable - this is a bit hacky but works
|
||||
// @ts-expect-error - accessing module-level variable from another module
|
||||
const serverStartedByApp = window.__voiceboxServerStartedByApp ?? false;
|
||||
|
||||
if (!keepRunning && serverStartedByApp) {
|
||||
// Stop server before closing (only if we started it)
|
||||
try {
|
||||
await stopServer();
|
||||
} catch (error) {
|
||||
console.error('Failed to stop server on close:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit event back to Rust to allow close
|
||||
await emit('window-close-allowed');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to setup window close handler:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import type { Platform } from './types';
|
||||
|
||||
const PlatformContext = createContext<Platform | null>(null);
|
||||
|
||||
export interface PlatformProviderProps {
|
||||
platform: Platform;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PlatformProvider({ platform, children }: PlatformProviderProps) {
|
||||
return (
|
||||
<PlatformContext.Provider value={platform}>
|
||||
{children}
|
||||
</PlatformContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePlatform(): Platform {
|
||||
const platform = useContext(PlatformContext);
|
||||
if (!platform) {
|
||||
throw new Error('usePlatform must be used within PlatformProvider');
|
||||
}
|
||||
return platform;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Platform abstraction types
|
||||
* These interfaces define the contract that platform implementations must fulfill
|
||||
*/
|
||||
|
||||
export interface FileFilter {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
export interface PlatformFilesystem {
|
||||
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
|
||||
/**
|
||||
* 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 {
|
||||
checking: boolean;
|
||||
available: boolean;
|
||||
version?: string;
|
||||
downloading: boolean;
|
||||
installing: boolean;
|
||||
readyToInstall: boolean;
|
||||
error?: string;
|
||||
downloadProgress?: number; // 0-100 percentage
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
}
|
||||
|
||||
export interface PlatformUpdater {
|
||||
checkForUpdates(): Promise<void>;
|
||||
downloadAndInstall(): Promise<void>;
|
||||
restartAndInstall(): Promise<void>;
|
||||
getStatus(): UpdateStatus;
|
||||
subscribe(callback: (status: UpdateStatus) => void): () => void;
|
||||
}
|
||||
|
||||
export interface AudioDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformAudio {
|
||||
isSystemAudioSupported(): boolean;
|
||||
startSystemAudioCapture(maxDurationSecs: number): Promise<void>;
|
||||
stopSystemAudioCapture(): Promise<Blob>;
|
||||
listOutputDevices(): Promise<AudioDevice[]>;
|
||||
playToDevices(audioData: Uint8Array, deviceIds: string[]): Promise<void>;
|
||||
stopPlayback(): void;
|
||||
}
|
||||
|
||||
export interface PlatformLifecycle {
|
||||
startServer(remote?: boolean): Promise<string>;
|
||||
stopServer(): Promise<void>;
|
||||
setKeepServerRunning(keep: boolean): Promise<void>;
|
||||
setupWindowCloseHandler(): Promise<void>;
|
||||
onServerReady?: () => void;
|
||||
}
|
||||
|
||||
export interface PlatformMetadata {
|
||||
getVersion(): Promise<string>;
|
||||
isTauri: boolean;
|
||||
}
|
||||
|
||||
export interface Platform {
|
||||
filesystem: PlatformFilesystem;
|
||||
updater: PlatformUpdater;
|
||||
audio: PlatformAudio;
|
||||
lifecycle: PlatformLifecycle;
|
||||
metadata: PlatformMetadata;
|
||||
}
|
||||
+2
-1
@@ -10,7 +10,8 @@ import { Toaster } from '@/components/ui/toaster';
|
||||
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
||||
import { isMacOS } from '@/lib/tauri';
|
||||
// Simple platform check that works in both web and Tauri
|
||||
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
||||
|
||||
// Root layout component
|
||||
function RootLayout() {
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Backend package
|
||||
|
||||
__version__ = "0.1.10"
|
||||
__version__ = "0.1.13"
|
||||
|
||||
@@ -118,22 +118,37 @@ _stt_backend: Optional[STTBackend] = None
|
||||
def get_tts_backend() -> TTSBackend:
|
||||
"""
|
||||
Get or create TTS backend instance based on platform.
|
||||
|
||||
|
||||
Returns:
|
||||
TTS backend instance (MLX or PyTorch)
|
||||
|
||||
Raises:
|
||||
ImportError: If required dependencies (mlx or torch) are not available
|
||||
"""
|
||||
global _tts_backend
|
||||
|
||||
|
||||
if _tts_backend is None:
|
||||
backend_type = get_backend_type()
|
||||
|
||||
|
||||
if backend_type == "mlx":
|
||||
from .mlx_backend import MLXTTSBackend
|
||||
_tts_backend = MLXTTSBackend()
|
||||
try:
|
||||
from .mlx_backend import MLXTTSBackend
|
||||
_tts_backend = MLXTTSBackend()
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
f"MLX backend dependencies not available. "
|
||||
f"Please install mlx and mlx_audio or download a provider. Error: {e}"
|
||||
)
|
||||
else:
|
||||
from .pytorch_backend import PyTorchTTSBackend
|
||||
_tts_backend = PyTorchTTSBackend()
|
||||
|
||||
try:
|
||||
from .pytorch_backend import PyTorchTTSBackend
|
||||
_tts_backend = PyTorchTTSBackend()
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
f"PyTorch backend dependencies not available. "
|
||||
f"Please download a TTS provider (pytorch-cpu or pytorch-cuda) from the Downloads page. Error: {e}"
|
||||
)
|
||||
|
||||
return _tts_backend
|
||||
|
||||
|
||||
|
||||
+198
-70
@@ -52,6 +52,47 @@ class MLXTTSBackend:
|
||||
|
||||
return hf_model_id
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
"""
|
||||
Check if the model is already cached locally AND fully downloaded.
|
||||
|
||||
Args:
|
||||
model_size: Model size to check
|
||||
|
||||
Returns:
|
||||
True if model is fully cached, False if missing or incomplete
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
model_path = self._get_model_path(model_size)
|
||||
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
|
||||
|
||||
if not repo_cache.exists():
|
||||
return False
|
||||
|
||||
# Check for .incomplete files - if any exist, download is still in progress
|
||||
blobs_dir = repo_cache / "blobs"
|
||||
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||
print(f"[_is_model_cached] Found .incomplete files for {model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
# Check that actual model weight files exist in snapshots
|
||||
snapshots_dir = repo_cache / "snapshots"
|
||||
if snapshots_dir.exists():
|
||||
has_weights = (
|
||||
any(snapshots_dir.rglob("*.safetensors")) or
|
||||
any(snapshots_dir.rglob("*.bin")) or
|
||||
any(snapshots_dir.rglob("*.npz"))
|
||||
)
|
||||
if not has_weights:
|
||||
print(f"[_is_model_cached] No model weights found for {model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[_is_model_cached] Error checking cache for {model_size}: {e}")
|
||||
return False
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
"""
|
||||
Lazy load the MLX TTS model.
|
||||
@@ -79,46 +120,63 @@ class MLXTTSBackend:
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
try:
|
||||
from mlx_audio.tts import load
|
||||
|
||||
# Get model path
|
||||
# Get model path BEFORE importing mlx_audio
|
||||
model_path = self._get_model_path(model_size)
|
||||
|
||||
# Set up progress tracking
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
# Start tracking download task
|
||||
task_manager = get_task_manager()
|
||||
task_manager.start_download(model_name)
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback
|
||||
# If cached: filter out non-download progress
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
print(f"Loading MLX TTS model {model_size}...")
|
||||
|
||||
# Initialize progress state
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=1,
|
||||
filename="",
|
||||
status="downloading",
|
||||
)
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
# This provides immediate feedback while HuggingFace fetches metadata
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=0, # Will be updated once actual total is known
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Set up progress callback
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
# IMPORTANT: Patch tqdm BEFORE importing mlx_audio
|
||||
# Otherwise mlx_audio caches reference to original tqdm
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
|
||||
# Use progress tracker during download
|
||||
with tracker.patch_download():
|
||||
# Load MLX model (downloads automatically)
|
||||
# Import mlx_audio AFTER patching tqdm
|
||||
from mlx_audio.tts import load
|
||||
|
||||
# Load MLX model (downloads automatically)
|
||||
try:
|
||||
self.model = load(model_path)
|
||||
finally:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
|
||||
# Mark as complete
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
|
||||
print(f"MLX TTS model {model_size} loaded successfully")
|
||||
|
||||
except ImportError as e:
|
||||
@@ -175,7 +233,13 @@ class MLXTTSBackend:
|
||||
if cached_prompt is not None:
|
||||
# Return cached prompt (should be dict format)
|
||||
if isinstance(cached_prompt, dict):
|
||||
return cached_prompt, True
|
||||
# Validate that the cached audio file still exists
|
||||
cached_audio_path = cached_prompt.get("ref_audio") or cached_prompt.get("ref_audio_path")
|
||||
if cached_audio_path and Path(cached_audio_path).exists():
|
||||
return cached_prompt, True
|
||||
else:
|
||||
# Cached file no longer exists, invalidate cache
|
||||
print(f"Cached audio file not found: {cached_audio_path}, regenerating prompt")
|
||||
|
||||
# MLX voice prompt format - store audio path and text
|
||||
# The model will process this during generation
|
||||
@@ -263,6 +327,13 @@ class MLXTTSBackend:
|
||||
ref_audio = voice_prompt.get("ref_audio") or voice_prompt.get("ref_audio_path")
|
||||
ref_text = voice_prompt.get("ref_text", "")
|
||||
|
||||
# Validate that the audio file exists
|
||||
if ref_audio and not Path(ref_audio).exists():
|
||||
print(f"Warning: Audio file not found: {ref_audio}")
|
||||
print("This may be due to a cached voice prompt referencing a deleted temp file.")
|
||||
print("Regenerating without voice prompt.")
|
||||
ref_audio = None
|
||||
|
||||
# Check if model supports voice cloning via generate method
|
||||
# MLX API may support ref_audio parameter directly
|
||||
try:
|
||||
@@ -319,6 +390,47 @@ class MLXSTTBackend:
|
||||
"""Check if model is loaded."""
|
||||
return self.model is not None
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
"""
|
||||
Check if the Whisper model is already cached locally AND fully downloaded.
|
||||
|
||||
Args:
|
||||
model_size: Model size to check
|
||||
|
||||
Returns:
|
||||
True if model is fully cached, False if missing or incomplete
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
model_name = f"openai/whisper-{model_size}"
|
||||
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
|
||||
|
||||
if not repo_cache.exists():
|
||||
return False
|
||||
|
||||
# Check for .incomplete files - if any exist, download is still in progress
|
||||
blobs_dir = repo_cache / "blobs"
|
||||
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||
print(f"[_is_model_cached] Found .incomplete files for whisper-{model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
# Check that actual model weight files exist in snapshots
|
||||
snapshots_dir = repo_cache / "snapshots"
|
||||
if snapshots_dir.exists():
|
||||
has_weights = (
|
||||
any(snapshots_dir.rglob("*.safetensors")) or
|
||||
any(snapshots_dir.rglob("*.bin")) or
|
||||
any(snapshots_dir.rglob("*.npz"))
|
||||
)
|
||||
if not has_weights:
|
||||
print(f"[_is_model_cached] No model weights found for whisper-{model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[_is_model_cached] Error checking cache for whisper-{model_size}: {e}")
|
||||
return False
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
"""
|
||||
Lazy load the MLX Whisper model.
|
||||
@@ -341,44 +453,59 @@ class MLXSTTBackend:
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
try:
|
||||
from mlx_audio.asr import load
|
||||
|
||||
# MLX Whisper model naming
|
||||
model_name = f"mlx-community/whisper-{model_size}"
|
||||
|
||||
# Set up progress tracking
|
||||
progress_manager = get_progress_manager()
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
|
||||
# Start tracking download task
|
||||
task_manager = get_task_manager()
|
||||
task_manager.start_download(progress_model_name)
|
||||
|
||||
print(f"Loading MLX Whisper model {model_size}...")
|
||||
|
||||
# Initialize progress state
|
||||
progress_manager.update_progress(
|
||||
model_name=progress_model_name,
|
||||
current=0,
|
||||
total=1,
|
||||
filename="",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Set up progress callback
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback and tracker
|
||||
# If cached: filter out non-download progress
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
|
||||
# Use progress tracker during download
|
||||
with tracker.patch_download():
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
# Patch tqdm BEFORE importing mlx_audio
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
|
||||
# Import mlx_audio
|
||||
from mlx_audio.stt import load
|
||||
|
||||
# MLX Whisper uses the standard OpenAI models
|
||||
model_name = f"openai/whisper-{model_size}"
|
||||
|
||||
print(f"Loading MLX Whisper model {model_size}...")
|
||||
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(progress_model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
progress_manager.update_progress(
|
||||
model_name=progress_model_name,
|
||||
current=0,
|
||||
total=0,
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||
try:
|
||||
self.model = load(model_name)
|
||||
finally:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(progress_model_name)
|
||||
task_manager.complete_download(progress_model_name)
|
||||
|
||||
self.model_size = model_size
|
||||
|
||||
# Mark as complete
|
||||
progress_manager.mark_complete(progress_model_name)
|
||||
task_manager.complete_download(progress_model_name)
|
||||
|
||||
print(f"MLX Whisper model {model_size} loaded successfully")
|
||||
|
||||
except ImportError as e:
|
||||
@@ -412,34 +539,35 @@ class MLXSTTBackend:
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio to text.
|
||||
|
||||
|
||||
Args:
|
||||
audio_path: Path to audio file
|
||||
language: Optional language hint (en or zh)
|
||||
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
"""
|
||||
await self.load_model_async(None)
|
||||
|
||||
|
||||
def _transcribe_sync():
|
||||
"""Run synchronous transcription in thread pool."""
|
||||
# Load audio
|
||||
audio, sr = load_audio(audio_path, sample_rate=16000)
|
||||
|
||||
# MLX Whisper transcription
|
||||
# The API may vary - check mlx-audio documentation
|
||||
# For now, assuming similar API to PyTorch Whisper
|
||||
result = self.model.transcribe(audio, language=language)
|
||||
|
||||
# Extract text from result (format may vary)
|
||||
# MLX Whisper transcription using generate method
|
||||
# The generate method accepts audio path directly
|
||||
decode_options = {}
|
||||
if language:
|
||||
decode_options["language"] = language
|
||||
|
||||
result = self.model.generate(str(audio_path), **decode_options)
|
||||
|
||||
# Extract text from result
|
||||
if isinstance(result, str):
|
||||
return result.strip()
|
||||
elif isinstance(result, dict):
|
||||
return result.get("text", "").strip()
|
||||
elif hasattr(result, "text"):
|
||||
return result.text.strip()
|
||||
else:
|
||||
# Try to get text attribute
|
||||
return str(result).strip()
|
||||
|
||||
|
||||
# Run blocking transcription in thread pool
|
||||
return await asyncio.to_thread(_transcribe_sync)
|
||||
|
||||
@@ -58,6 +58,46 @@ class PyTorchTTSBackend:
|
||||
|
||||
return hf_model_map[model_size]
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
"""
|
||||
Check if the model is already cached locally AND fully downloaded.
|
||||
|
||||
Args:
|
||||
model_size: Model size to check
|
||||
|
||||
Returns:
|
||||
True if model is fully cached, False if missing or incomplete
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
model_path = self._get_model_path(model_size)
|
||||
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
|
||||
|
||||
if not repo_cache.exists():
|
||||
return False
|
||||
|
||||
# Check for .incomplete files - if any exist, download is still in progress
|
||||
blobs_dir = repo_cache / "blobs"
|
||||
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||
print(f"[_is_model_cached] Found .incomplete files for {model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
# Check that actual model weight files exist in snapshots
|
||||
snapshots_dir = repo_cache / "snapshots"
|
||||
if snapshots_dir.exists():
|
||||
has_weights = (
|
||||
any(snapshots_dir.rglob("*.safetensors")) or
|
||||
any(snapshots_dir.rglob("*.bin"))
|
||||
)
|
||||
if not has_weights:
|
||||
print(f"[_is_model_cached] No model weights found for {model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[_is_model_cached] Error checking cache for {model_size}: {e}")
|
||||
return False
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
"""
|
||||
Lazy load the TTS model with automatic downloading from HuggingFace Hub.
|
||||
@@ -85,46 +125,60 @@ class PyTorchTTSBackend:
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
try:
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback and tracker
|
||||
# If cached: filter out non-download progress (like "Segment 1/1" during generation)
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
# Patch tqdm BEFORE importing qwen_tts
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
|
||||
# Import qwen_tts
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
|
||||
# Get model path (local or HuggingFace Hub ID)
|
||||
model_path = self._get_model_path(model_size)
|
||||
|
||||
# Set up progress tracking
|
||||
progress_manager = get_progress_manager()
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
|
||||
print(f"Loading TTS model {model_size} on {self.device}...")
|
||||
|
||||
# Start tracking download task
|
||||
task_manager = get_task_manager()
|
||||
task_manager.start_download(model_name)
|
||||
|
||||
# Initialize progress state to show download has started
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=1, # Set to 1 initially, will be updated by callback
|
||||
filename="",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Set up progress callback
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
|
||||
# Use progress tracker during download
|
||||
with tracker.patch_download():
|
||||
# Load the model - downloads will happen automatically with progress tracking
|
||||
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=0, # Will be updated once actual total is known
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||
try:
|
||||
self.model = Qwen3TTSModel.from_pretrained(
|
||||
model_path,
|
||||
device_map=self.device,
|
||||
torch_dtype=torch.float32 if self.device == "cpu" else torch.bfloat16,
|
||||
)
|
||||
finally:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
# Mark as complete
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
@@ -188,6 +242,8 @@ class PyTorchTTSBackend:
|
||||
# Cache stores as torch.Tensor but actual prompt is dict
|
||||
# Convert if needed
|
||||
if isinstance(cached_prompt, dict):
|
||||
# For PyTorch backend, the dict should contain tensors, not file paths
|
||||
# So we can safely return it
|
||||
return cached_prompt, True
|
||||
elif isinstance(cached_prompt, torch.Tensor):
|
||||
# Legacy cache format - convert to dict
|
||||
@@ -311,67 +367,131 @@ class PyTorchSTTBackend:
|
||||
"""Check if model is loaded."""
|
||||
return self.model is not None
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
"""
|
||||
Check if the Whisper model is already cached locally AND fully downloaded.
|
||||
|
||||
Args:
|
||||
model_size: Model size to check
|
||||
|
||||
Returns:
|
||||
True if model is fully cached, False if missing or incomplete
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
model_name = f"openai/whisper-{model_size}"
|
||||
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
|
||||
|
||||
if not repo_cache.exists():
|
||||
return False
|
||||
|
||||
# Check for .incomplete files - if any exist, download is still in progress
|
||||
blobs_dir = repo_cache / "blobs"
|
||||
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||
print(f"[_is_model_cached] Found .incomplete files for whisper-{model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
# Check that actual model weight files exist in snapshots
|
||||
snapshots_dir = repo_cache / "snapshots"
|
||||
if snapshots_dir.exists():
|
||||
has_weights = (
|
||||
any(snapshots_dir.rglob("*.safetensors")) or
|
||||
any(snapshots_dir.rglob("*.bin"))
|
||||
)
|
||||
if not has_weights:
|
||||
print(f"[_is_model_cached] No model weights found for whisper-{model_size}, treating as not cached")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[_is_model_cached] Error checking cache for whisper-{model_size}: {e}")
|
||||
return False
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
"""
|
||||
Lazy load the Whisper model.
|
||||
|
||||
|
||||
Args:
|
||||
model_size: Model size (tiny, base, small, medium, large)
|
||||
"""
|
||||
print(f"[DEBUG] load_model_async called with size: {model_size}")
|
||||
if model_size is None:
|
||||
model_size = self.model_size
|
||||
|
||||
|
||||
print(f"[DEBUG] Model already loaded? {self.model is not None}, current size: {self.model_size}, requested: {model_size}")
|
||||
if self.model is not None and self.model_size == model_size:
|
||||
print(f"[DEBUG] Early return - model already loaded")
|
||||
return
|
||||
|
||||
|
||||
print(f"[DEBUG] Calling asyncio.to_thread for _load_model_sync")
|
||||
# Run blocking load in thread pool
|
||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||
print(f"[DEBUG] asyncio.to_thread completed")
|
||||
|
||||
# Alias for compatibility
|
||||
load_model = load_model_async
|
||||
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
print(f"[DEBUG] _load_model_sync called for Whisper {model_size}")
|
||||
try:
|
||||
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||
|
||||
model_name = f"openai/whisper-{model_size}"
|
||||
|
||||
# Set up progress tracking
|
||||
progress_manager = get_progress_manager()
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
|
||||
# Start tracking download task
|
||||
task_manager = get_task_manager()
|
||||
task_manager.start_download(progress_model_name)
|
||||
|
||||
print(f"Loading Whisper model {model_size} on {self.device}...")
|
||||
|
||||
# Initialize progress state to show download has started
|
||||
progress_manager.update_progress(
|
||||
model_name=progress_model_name,
|
||||
current=0,
|
||||
total=1, # Set to 1 initially, will be updated by callback
|
||||
filename="",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Set up progress callback
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback and tracker
|
||||
# If cached: filter out non-download progress
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
|
||||
# Use progress tracker during download
|
||||
with tracker.patch_download():
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
# Patch tqdm BEFORE importing transformers
|
||||
print("[DEBUG] Starting tqdm patch BEFORE transformers import")
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
print("[DEBUG] tqdm patched, now importing transformers")
|
||||
|
||||
# Import transformers
|
||||
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||
|
||||
model_name = f"openai/whisper-{model_size}"
|
||||
print(f"[DEBUG] Model name: {model_name}")
|
||||
|
||||
print(f"Loading Whisper model {model_size} on {self.device}...")
|
||||
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(progress_model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
progress_manager.update_progress(
|
||||
model_name=progress_model_name,
|
||||
current=0,
|
||||
total=0, # Will be updated once actual total is known
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Load models (tqdm is patched, but filters out non-download progress)
|
||||
try:
|
||||
self.processor = WhisperProcessor.from_pretrained(model_name)
|
||||
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
||||
finally:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(progress_model_name)
|
||||
task_manager.complete_download(progress_model_name)
|
||||
|
||||
self.model.to(self.device)
|
||||
self.model_size = model_size
|
||||
|
||||
# Mark as complete
|
||||
progress_manager.mark_complete(progress_model_name)
|
||||
task_manager.complete_download(progress_model_name)
|
||||
|
||||
print(f"Whisper model {model_size} loaded successfully")
|
||||
|
||||
except Exception as e:
|
||||
|
||||
+45
-18
@@ -30,7 +30,7 @@ def build_server():
|
||||
args.extend(['--paths', str(qwen_tts_path)])
|
||||
print(f"Using local qwen_tts source from: {qwen_tts_path}")
|
||||
|
||||
# Add common hidden imports
|
||||
# Add common hidden imports (always included)
|
||||
args.extend([
|
||||
'--hidden-import', 'backend',
|
||||
'--hidden-import', 'backend.main',
|
||||
@@ -42,53 +42,80 @@ def build_server():
|
||||
'--hidden-import', 'backend.tts',
|
||||
'--hidden-import', 'backend.transcribe',
|
||||
'--hidden-import', 'backend.platform_detect',
|
||||
'--hidden-import', 'backend.backends',
|
||||
'--hidden-import', 'backend.backends.pytorch_backend',
|
||||
'--hidden-import', 'backend.providers',
|
||||
'--hidden-import', 'backend.providers.base',
|
||||
'--hidden-import', 'backend.providers.bundled',
|
||||
'--hidden-import', 'backend.providers.types',
|
||||
'--hidden-import', 'backend.utils.audio',
|
||||
'--hidden-import', 'backend.utils.cache',
|
||||
'--hidden-import', 'backend.utils.progress',
|
||||
'--hidden-import', 'backend.utils.hf_progress',
|
||||
'--hidden-import', 'backend.utils.validation',
|
||||
'--hidden-import', 'torch',
|
||||
'--hidden-import', 'transformers',
|
||||
'--hidden-import', 'numpy',
|
||||
'--hidden-import', 'numpy.core',
|
||||
'--hidden-import', 'numpy.core._multiarray_umath',
|
||||
'--hidden-import', 'scipy',
|
||||
'--hidden-import', 'scipy.signal',
|
||||
'--hidden-import', 'fastapi',
|
||||
'--hidden-import', 'uvicorn',
|
||||
'--hidden-import', 'sqlalchemy',
|
||||
'--hidden-import', 'librosa',
|
||||
'--hidden-import', 'soundfile',
|
||||
'--hidden-import', 'qwen_tts',
|
||||
'--hidden-import', 'qwen_tts.inference',
|
||||
'--hidden-import', 'qwen_tts.inference.qwen3_tts_model',
|
||||
'--hidden-import', 'qwen_tts.inference.qwen3_tts_tokenizer',
|
||||
'--hidden-import', 'qwen_tts.core',
|
||||
'--hidden-import', 'qwen_tts.cli',
|
||||
'--copy-metadata', 'qwen-tts',
|
||||
'--collect-submodules', 'qwen_tts',
|
||||
'--collect-data', 'qwen_tts',
|
||||
# Fix for pkg_resources and jaraco namespace packages
|
||||
'--hidden-import', 'pkg_resources.extern',
|
||||
'--collect-submodules', 'jaraco',
|
||||
# Asyncio and threading support for PyInstaller
|
||||
'--hidden-import', 'asyncio',
|
||||
'--hidden-import', 'asyncio.subprocess',
|
||||
'--hidden-import', 'concurrent.futures',
|
||||
'--hidden-import', 'concurrent.futures.thread',
|
||||
])
|
||||
|
||||
# Add MLX-specific imports if building on Apple Silicon
|
||||
# Platform-specific TTS backend handling
|
||||
system = platform.system()
|
||||
|
||||
if is_apple_silicon():
|
||||
print("Building for Apple Silicon - including MLX dependencies")
|
||||
print("Building for Apple Silicon - including MLX dependencies (bundled)")
|
||||
args.extend([
|
||||
'--hidden-import', 'backend.backends',
|
||||
'--hidden-import', 'backend.backends.mlx_backend',
|
||||
'--hidden-import', 'mlx',
|
||||
'--hidden-import', 'mlx.core',
|
||||
'--hidden-import', 'mlx.nn',
|
||||
'--hidden-import', 'mlx_audio',
|
||||
'--hidden-import', 'mlx_audio.tts',
|
||||
'--hidden-import', 'mlx_audio.asr',
|
||||
'--hidden-import', 'mlx_audio.stt',
|
||||
'--collect-submodules', 'mlx',
|
||||
'--collect-submodules', 'mlx_audio',
|
||||
# Collect MLX data files including Metal shader libraries (.metallib)
|
||||
'--collect-data', 'mlx',
|
||||
'--collect-data', 'mlx_audio',
|
||||
])
|
||||
elif system == "Windows" or (system == "Darwin" and not is_apple_silicon()):
|
||||
# Windows and Intel macOS: Bundle PyTorch CPU provider
|
||||
print(f"Building for {system} - including PyTorch CPU provider (bundled)")
|
||||
args.extend([
|
||||
'--hidden-import', 'backend.backends',
|
||||
'--hidden-import', 'backend.backends.pytorch_backend',
|
||||
'--hidden-import', 'torch',
|
||||
'--hidden-import', 'transformers',
|
||||
'--hidden-import', 'qwen_tts',
|
||||
'--hidden-import', 'qwen_tts.inference',
|
||||
'--hidden-import', 'qwen_tts.inference.qwen3_tts_model',
|
||||
'--hidden-import', 'qwen_tts.inference.qwen3_tts_tokenizer',
|
||||
'--hidden-import', 'qwen_tts.core',
|
||||
'--hidden-import', 'qwen_tts.cli',
|
||||
'--copy-metadata', 'qwen-tts',
|
||||
'--collect-submodules', 'qwen_tts',
|
||||
'--collect-data', 'qwen_tts',
|
||||
])
|
||||
else:
|
||||
print("Building for non-Apple Silicon platform - PyTorch only")
|
||||
# Linux: No bundled provider - users download providers separately
|
||||
print("Building for Linux - no bundled provider (users download separately)")
|
||||
args.extend([
|
||||
'--hidden-import', 'backend.backends',
|
||||
'--hidden-import', 'backend.backends.pytorch_backend',
|
||||
])
|
||||
|
||||
args.extend([
|
||||
'--noconfirm',
|
||||
|
||||
+411
-91
@@ -14,7 +14,6 @@ from datetime import datetime
|
||||
import asyncio
|
||||
import uvicorn
|
||||
import argparse
|
||||
import torch
|
||||
import tempfile
|
||||
import io
|
||||
from pathlib import Path
|
||||
@@ -23,11 +22,22 @@ import asyncio
|
||||
import signal
|
||||
import os
|
||||
|
||||
# Optional torch import - not available on all platforms (e.g. Windows/Linux without bundled provider)
|
||||
try:
|
||||
import torch
|
||||
TORCH_AVAILABLE = True
|
||||
except ImportError:
|
||||
torch = None # type: ignore
|
||||
TORCH_AVAILABLE = False
|
||||
|
||||
from . import database, models, profiles, history, tts, transcribe, config, export_import, channels, stories, __version__
|
||||
from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||
from .utils.progress import get_progress_manager
|
||||
from .utils.tasks import get_task_manager
|
||||
from .utils.cache import clear_voice_prompt_cache
|
||||
from .platform_detect import get_backend_type
|
||||
from .providers import get_provider_manager
|
||||
from .providers.types import ProviderType
|
||||
|
||||
app = FastAPI(
|
||||
title="voicebox API",
|
||||
@@ -49,10 +59,8 @@ app.add_middleware(
|
||||
# ROOT & HEALTH ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint."""
|
||||
return {"message": "voicebox API", "version": __version__}
|
||||
# Root endpoint removed - web UI served at / instead
|
||||
# API info available at /health
|
||||
|
||||
|
||||
@app.post("/shutdown")
|
||||
@@ -66,23 +74,48 @@ async def shutdown():
|
||||
return {"message": "Shutting down..."}
|
||||
|
||||
|
||||
@app.get("/system/folders", response_model=models.FolderPathsResponse)
|
||||
async def get_system_folders():
|
||||
"""Get system folder paths for data, models, and providers."""
|
||||
from huggingface_hub import constants as hf_constants
|
||||
from .providers.installer import _get_providers_dir
|
||||
|
||||
return models.FolderPathsResponse(
|
||||
data_dir=str(config.get_data_dir().absolute()),
|
||||
models_dir=str(Path(hf_constants.HF_HUB_CACHE).absolute()),
|
||||
providers_dir=str(_get_providers_dir().absolute()),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health", response_model=models.HealthResponse)
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
from huggingface_hub import hf_hub_download, constants as hf_constants
|
||||
from huggingface_hub import constants as hf_constants
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
tts_model = tts.get_tts_model()
|
||||
# Try to get TTS model provider, but it may not be available if dependencies aren't installed
|
||||
tts_model = None
|
||||
try:
|
||||
tts_model = await tts.get_tts_model_async()
|
||||
except ImportError as e:
|
||||
# Provider dependencies not available (e.g., PyTorch not bundled on this platform)
|
||||
# This is expected on Windows/Linux builds without a bundled provider
|
||||
print(f"Provider not available: {e}")
|
||||
|
||||
backend_type = get_backend_type()
|
||||
|
||||
# Check for GPU availability (CUDA or MPS)
|
||||
has_cuda = torch.cuda.is_available()
|
||||
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
||||
# PyTorch might not be available if no provider is bundled
|
||||
has_cuda = False
|
||||
has_mps = False
|
||||
if TORCH_AVAILABLE and torch is not None:
|
||||
has_cuda = torch.cuda.is_available()
|
||||
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
||||
|
||||
gpu_available = has_cuda or has_mps
|
||||
|
||||
gpu_type = None
|
||||
if has_cuda:
|
||||
if has_cuda and torch is not None:
|
||||
gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})"
|
||||
elif has_mps:
|
||||
gpu_type = "MPS (Apple Silicon)"
|
||||
@@ -90,26 +123,27 @@ async def health():
|
||||
gpu_type = "Metal (Apple Silicon via MLX)"
|
||||
|
||||
vram_used = None
|
||||
if has_cuda:
|
||||
if has_cuda and torch is not None:
|
||||
vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB
|
||||
|
||||
|
||||
# Check if model is loaded - use the same logic as model status endpoint
|
||||
model_loaded = False
|
||||
model_size = None
|
||||
try:
|
||||
# Use the same check as model status endpoint
|
||||
if tts_model.is_loaded():
|
||||
model_loaded = True
|
||||
# Get the actual loaded model size
|
||||
# Check _current_model_size first (more reliable for actually loaded models)
|
||||
model_size = getattr(tts_model, '_current_model_size', None)
|
||||
if not model_size:
|
||||
# Fallback to model_size attribute (which should be set when model loads)
|
||||
model_size = getattr(tts_model, 'model_size', None)
|
||||
except Exception:
|
||||
# If there's an error checking, assume not loaded
|
||||
model_loaded = False
|
||||
model_size = None
|
||||
if tts_model is not None:
|
||||
try:
|
||||
# Use the same check as model status endpoint
|
||||
if tts_model.is_loaded():
|
||||
model_loaded = True
|
||||
# Get the actual loaded model size
|
||||
# Check _current_model_size first (more reliable for actually loaded models)
|
||||
model_size = getattr(tts_model, '_current_model_size', None)
|
||||
if not model_size:
|
||||
# Fallback to model_size attribute (which should be set when model loads)
|
||||
model_size = getattr(tts_model, 'model_size', None)
|
||||
except Exception:
|
||||
# If there's an error checking, assume not loaded
|
||||
model_loaded = False
|
||||
model_size = None
|
||||
|
||||
# Check if default model is downloaded (cached)
|
||||
model_downloaded = None
|
||||
@@ -548,7 +582,7 @@ async def generate_speech(
|
||||
)
|
||||
|
||||
# Generate audio
|
||||
tts_model = tts.get_tts_model()
|
||||
tts_model = await tts.get_tts_model_async()
|
||||
# Load the requested model size if different from current (async to not block)
|
||||
model_size = data.model_size or "1.7B"
|
||||
|
||||
@@ -1112,8 +1146,8 @@ async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)):
|
||||
async def load_model(model_size: str = "1.7B"):
|
||||
"""Manually load TTS model."""
|
||||
try:
|
||||
tts_model = tts.get_tts_model()
|
||||
await tts_model.load_model_async(model_size)
|
||||
tts_model = await tts.get_tts_model_async()
|
||||
await tts_model.load_model(model_size)
|
||||
return {"message": f"Model {model_size} loaded successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -1155,11 +1189,14 @@ async def get_model_progress(model_name: str):
|
||||
@app.get("/models/status", response_model=models.ModelStatusListResponse)
|
||||
async def get_model_status():
|
||||
"""Get status of all available models."""
|
||||
from huggingface_hub import hf_hub_download, constants as hf_constants
|
||||
from huggingface_hub import constants as hf_constants
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
backend_type = get_backend_type()
|
||||
task_manager = get_task_manager()
|
||||
|
||||
# Get set of currently downloading model names
|
||||
active_download_names = {task.model_name for task in task_manager.get_active_downloads()}
|
||||
|
||||
# Try to import scan_cache_dir (might not be available in older versions)
|
||||
try:
|
||||
@@ -1168,10 +1205,10 @@ async def get_model_status():
|
||||
except ImportError:
|
||||
use_scan_cache = False
|
||||
|
||||
def check_tts_loaded(model_size: str):
|
||||
async def check_tts_loaded(model_size: str):
|
||||
"""Check if TTS model is loaded with specific size."""
|
||||
try:
|
||||
tts_model = tts.get_tts_model()
|
||||
tts_model = await tts.get_tts_model_async()
|
||||
return tts_model.is_loaded() and getattr(tts_model, 'model_size', None) == model_size
|
||||
except Exception:
|
||||
return False
|
||||
@@ -1188,10 +1225,11 @@ async def get_model_status():
|
||||
if backend_type == "mlx":
|
||||
tts_1_7b_id = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
|
||||
tts_0_6b_id = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16" # Fallback to 1.7B
|
||||
whisper_base_id = "mlx-community/whisper-base"
|
||||
whisper_small_id = "mlx-community/whisper-small"
|
||||
whisper_medium_id = "mlx-community/whisper-medium"
|
||||
whisper_large_id = "mlx-community/whisper-large"
|
||||
# MLX backend uses openai/whisper-* models, not mlx-community
|
||||
whisper_base_id = "openai/whisper-base"
|
||||
whisper_small_id = "openai/whisper-small"
|
||||
whisper_medium_id = "openai/whisper-medium"
|
||||
whisper_large_id = "openai/whisper-large"
|
||||
else:
|
||||
tts_1_7b_id = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||
tts_0_6b_id = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
|
||||
@@ -1206,14 +1244,14 @@ async def get_model_status():
|
||||
"display_name": "Qwen TTS 1.7B",
|
||||
"hf_repo_id": tts_1_7b_id,
|
||||
"model_size": "1.7B",
|
||||
"check_loaded": lambda: check_tts_loaded("1.7B"),
|
||||
"check_loaded": lambda: check_tts_loaded("1.7B"), # Async function
|
||||
},
|
||||
{
|
||||
"model_name": "qwen-tts-0.6B",
|
||||
"display_name": "Qwen TTS 0.6B",
|
||||
"hf_repo_id": tts_0_6b_id,
|
||||
"model_size": "0.6B",
|
||||
"check_loaded": lambda: check_tts_loaded("0.6B"),
|
||||
"check_loaded": lambda: check_tts_loaded("0.6B"), # Async function
|
||||
},
|
||||
{
|
||||
"model_name": "whisper-base",
|
||||
@@ -1245,6 +1283,13 @@ async def get_model_status():
|
||||
},
|
||||
]
|
||||
|
||||
# Build a mapping of model_name -> hf_repo_id so we can check if shared repos are downloading
|
||||
model_to_repo = {cfg["model_name"]: cfg["hf_repo_id"] for cfg in model_configs}
|
||||
|
||||
# Get the set of hf_repo_ids that are currently being downloaded
|
||||
# This handles the case where multiple models share the same repo (e.g., 0.6B and 1.7B on MLX)
|
||||
active_download_repos = {model_to_repo.get(name) for name in active_download_names if name in model_to_repo}
|
||||
|
||||
# Get HuggingFace cache info (if available)
|
||||
cache_info = None
|
||||
if use_scan_cache:
|
||||
@@ -1267,13 +1312,37 @@ async def get_model_status():
|
||||
repo_id = config["hf_repo_id"]
|
||||
for repo in cache_info.repos:
|
||||
if repo.repo_id == repo_id:
|
||||
downloaded = True
|
||||
# Calculate size from cache info
|
||||
# Check if actual model weight files exist (not just config files)
|
||||
# scan_cache_dir only shows completed files, so check if any are model weights
|
||||
has_model_weights = False
|
||||
for rev in repo.revisions:
|
||||
for f in rev.files:
|
||||
fname = f.file_name.lower()
|
||||
if fname.endswith(('.safetensors', '.bin', '.pt', '.pth', '.npz')):
|
||||
has_model_weights = True
|
||||
break
|
||||
if has_model_weights:
|
||||
break
|
||||
|
||||
# Also check for .incomplete files in blobs directory (downloads in progress)
|
||||
has_incomplete = False
|
||||
try:
|
||||
total_size = sum(revision.size_on_disk for revision in repo.revisions)
|
||||
size_mb = total_size / (1024 * 1024)
|
||||
cache_dir = hf_constants.HF_HUB_CACHE
|
||||
blobs_dir = Path(cache_dir) / ("models--" + repo_id.replace("/", "--")) / "blobs"
|
||||
if blobs_dir.exists():
|
||||
has_incomplete = any(blobs_dir.glob("*.incomplete"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Only mark as downloaded if we have model weights AND no incomplete files
|
||||
if has_model_weights and not has_incomplete:
|
||||
downloaded = True
|
||||
# Calculate size from cache info
|
||||
try:
|
||||
total_size = sum(revision.size_on_disk for revision in repo.revisions)
|
||||
size_mb = total_size / (1024 * 1024)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
# Method 2: Fallback to checking cache directory directly (using HuggingFace's OS-specific cache location)
|
||||
@@ -1283,67 +1352,96 @@ async def get_model_status():
|
||||
repo_cache = Path(cache_dir) / ("models--" + config["hf_repo_id"].replace("/", "--"))
|
||||
|
||||
if repo_cache.exists():
|
||||
# Check for model files (bin, safetensors, or other common model files)
|
||||
# MLX models may use .npz or .safetensors
|
||||
has_model_files = (
|
||||
any(repo_cache.rglob("*.bin")) or
|
||||
any(repo_cache.rglob("*.safetensors")) or
|
||||
any(repo_cache.rglob("*.pt")) or
|
||||
any(repo_cache.rglob("*.pth")) or
|
||||
any(repo_cache.rglob("*.npz")) or
|
||||
any(repo_cache.rglob("model.safetensors.index.json")) or
|
||||
any(repo_cache.rglob("pytorch_model.bin.index.json"))
|
||||
)
|
||||
# Check for .incomplete files - if any exist, download is still in progress
|
||||
blobs_dir = repo_cache / "blobs"
|
||||
has_incomplete = blobs_dir.exists() and any(blobs_dir.glob("*.incomplete"))
|
||||
|
||||
if has_model_files:
|
||||
downloaded = True
|
||||
# Calculate size
|
||||
try:
|
||||
total_size = sum(f.stat().st_size for f in repo_cache.rglob("*") if f.is_file())
|
||||
size_mb = total_size / (1024 * 1024)
|
||||
except Exception:
|
||||
pass
|
||||
if not has_incomplete:
|
||||
# Check for actual model weight files (not just index files)
|
||||
# in the snapshots directory (symlinks to completed blobs)
|
||||
snapshots_dir = repo_cache / "snapshots"
|
||||
has_model_files = False
|
||||
if snapshots_dir.exists():
|
||||
has_model_files = (
|
||||
any(snapshots_dir.rglob("*.bin")) or
|
||||
any(snapshots_dir.rglob("*.safetensors")) or
|
||||
any(snapshots_dir.rglob("*.pt")) or
|
||||
any(snapshots_dir.rglob("*.pth")) or
|
||||
any(snapshots_dir.rglob("*.npz"))
|
||||
)
|
||||
|
||||
if has_model_files:
|
||||
downloaded = True
|
||||
# Calculate size (exclude .incomplete files)
|
||||
try:
|
||||
total_size = sum(
|
||||
f.stat().st_size for f in repo_cache.rglob("*")
|
||||
if f.is_file() and not f.name.endswith('.incomplete')
|
||||
)
|
||||
size_mb = total_size / (1024 * 1024)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Method 3: Try to check if model can be loaded locally (last resort)
|
||||
if not downloaded:
|
||||
try:
|
||||
# Try to download with local_files_only=True to check if cached
|
||||
hf_hub_download(
|
||||
repo_id=config["hf_repo_id"],
|
||||
filename="config.json", # Try a common file
|
||||
local_files_only=True,
|
||||
)
|
||||
downloaded = True
|
||||
except Exception:
|
||||
# File not found locally, model not downloaded
|
||||
pass
|
||||
# Method 3 removed - checking for config.json is too lenient
|
||||
# Methods 1 and 2 properly verify that model weight files exist
|
||||
|
||||
# Check if loaded in memory
|
||||
try:
|
||||
loaded = config["check_loaded"]()
|
||||
check_func = config["check_loaded"]
|
||||
if asyncio.iscoroutinefunction(check_func):
|
||||
loaded = await check_func()
|
||||
else:
|
||||
result = check_func()
|
||||
# Handle lambdas that return coroutines
|
||||
if asyncio.iscoroutine(result):
|
||||
loaded = await result
|
||||
else:
|
||||
loaded = result
|
||||
except Exception:
|
||||
loaded = False
|
||||
|
||||
# Check if this model (or its shared repo) is currently being downloaded
|
||||
is_downloading = config["hf_repo_id"] in active_download_repos
|
||||
|
||||
# If downloading, don't report as downloaded (partial files exist)
|
||||
if is_downloading:
|
||||
downloaded = False
|
||||
size_mb = None # Don't show partial size during download
|
||||
|
||||
statuses.append(models.ModelStatus(
|
||||
model_name=config["model_name"],
|
||||
display_name=config["display_name"],
|
||||
downloaded=downloaded,
|
||||
downloading=is_downloading,
|
||||
size_mb=size_mb,
|
||||
loaded=loaded,
|
||||
))
|
||||
except Exception as e:
|
||||
# If check fails, try to at least check if loaded
|
||||
try:
|
||||
loaded = config["check_loaded"]()
|
||||
check_func = config["check_loaded"]
|
||||
if asyncio.iscoroutinefunction(check_func):
|
||||
loaded = await check_func()
|
||||
else:
|
||||
result = check_func()
|
||||
# Handle lambdas that return coroutines
|
||||
if asyncio.iscoroutine(result):
|
||||
loaded = await result
|
||||
else:
|
||||
loaded = result
|
||||
except Exception:
|
||||
loaded = False
|
||||
|
||||
# Check if this model (or its shared repo) is currently being downloaded
|
||||
is_downloading = config["hf_repo_id"] in active_download_repos
|
||||
|
||||
statuses.append(models.ModelStatus(
|
||||
model_name=config["model_name"],
|
||||
display_name=config["display_name"],
|
||||
downloaded=False, # Assume not downloaded if check failed
|
||||
downloading=is_downloading,
|
||||
size_mb=None,
|
||||
loaded=loaded,
|
||||
))
|
||||
@@ -1357,15 +1455,26 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
||||
import asyncio
|
||||
|
||||
task_manager = get_task_manager()
|
||||
progress_manager = get_progress_manager()
|
||||
|
||||
async def load_tts_model_1_7b():
|
||||
"""Load 1.7B TTS model."""
|
||||
tts_model = await tts.get_tts_model_async()
|
||||
await tts_model.load_model("1.7B")
|
||||
|
||||
async def load_tts_model_0_6b():
|
||||
"""Load 0.6B TTS model."""
|
||||
tts_model = await tts.get_tts_model_async()
|
||||
await tts_model.load_model("0.6B")
|
||||
|
||||
model_configs = {
|
||||
"qwen-tts-1.7B": {
|
||||
"model_size": "1.7B",
|
||||
"load_func": lambda: tts.get_tts_model().load_model("1.7B"),
|
||||
"load_func": load_tts_model_1_7b,
|
||||
},
|
||||
"qwen-tts-0.6B": {
|
||||
"model_size": "0.6B",
|
||||
"load_func": lambda: tts.get_tts_model().load_model("0.6B"),
|
||||
"load_func": load_tts_model_0_6b,
|
||||
},
|
||||
"whisper-base": {
|
||||
"model_size": "base",
|
||||
@@ -1393,13 +1502,29 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
||||
async def download_in_background():
|
||||
"""Download model in background without blocking the HTTP request."""
|
||||
try:
|
||||
await asyncio.to_thread(config["load_func"])
|
||||
# Call the load function (which may be async)
|
||||
result = config["load_func"]()
|
||||
# If it's a coroutine, await it
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
task_manager.complete_download(request.model_name)
|
||||
except Exception as e:
|
||||
task_manager.error_download(request.model_name, str(e))
|
||||
|
||||
# Start tracking download
|
||||
task_manager.start_download(request.model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send.
|
||||
# This fixes a race condition where the frontend connects to SSE before
|
||||
# any progress callbacks have fired (especially for large models like Qwen
|
||||
# where huggingface_hub takes time to fetch metadata for all files).
|
||||
progress_manager.update_progress(
|
||||
model_name=request.model_name,
|
||||
current=0,
|
||||
total=0, # Will be updated once actual total is known
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Start download in background task (don't await)
|
||||
asyncio.create_task(download_in_background())
|
||||
@@ -1408,6 +1533,171 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
||||
return {"message": f"Model {request.model_name} download started"}
|
||||
|
||||
|
||||
# ============================================
|
||||
# PROVIDER ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@app.get("/providers")
|
||||
async def list_providers():
|
||||
"""List all available provider types."""
|
||||
manager = get_provider_manager()
|
||||
installed = await manager.list_installed()
|
||||
|
||||
# Get info for all known provider types
|
||||
all_providers = [
|
||||
"apple-mlx",
|
||||
"bundled-pytorch",
|
||||
"pytorch-cpu",
|
||||
"pytorch-cuda",
|
||||
"remote",
|
||||
"openai",
|
||||
]
|
||||
|
||||
providers_info = []
|
||||
for provider_type in all_providers:
|
||||
info = await manager.get_provider_info(provider_type)
|
||||
providers_info.append(info)
|
||||
|
||||
return {
|
||||
"providers": providers_info,
|
||||
"installed": installed,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/providers/installed")
|
||||
async def list_installed_providers():
|
||||
"""List installed provider types."""
|
||||
manager = get_provider_manager()
|
||||
installed = await manager.list_installed()
|
||||
return {"installed": installed}
|
||||
|
||||
|
||||
@app.get("/providers/active")
|
||||
async def get_active_provider():
|
||||
"""Get information about the currently active provider."""
|
||||
manager = get_provider_manager()
|
||||
provider = await manager.get_active_provider()
|
||||
|
||||
health = await provider.health()
|
||||
status = await provider.status()
|
||||
|
||||
return {
|
||||
"provider": health["provider"],
|
||||
"health": health,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/providers/start")
|
||||
async def start_provider(data: dict):
|
||||
"""Start a specific provider."""
|
||||
provider_type = data.get("provider_type")
|
||||
if not provider_type:
|
||||
raise HTTPException(status_code=400, detail="provider_type is required")
|
||||
|
||||
manager = get_provider_manager()
|
||||
try:
|
||||
await manager.start_provider(provider_type)
|
||||
provider = await manager.get_active_provider()
|
||||
health = await provider.health()
|
||||
return {
|
||||
"message": f"Provider {provider_type} started",
|
||||
"provider": health,
|
||||
}
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(status_code=501, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/providers/stop")
|
||||
async def stop_provider():
|
||||
"""Stop the currently active provider."""
|
||||
manager = get_provider_manager()
|
||||
await manager.stop_provider()
|
||||
return {"message": "Provider stopped"}
|
||||
|
||||
|
||||
@app.post("/providers/download")
|
||||
async def download_provider_endpoint(data: dict):
|
||||
"""Download a provider binary."""
|
||||
from .providers.installer import download_provider
|
||||
|
||||
provider_type = data.get("provider_type")
|
||||
if not provider_type:
|
||||
raise HTTPException(status_code=400, detail="provider_type is required")
|
||||
|
||||
if provider_type not in ["pytorch-cpu", "pytorch-cuda"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider type {provider_type} cannot be downloaded"
|
||||
)
|
||||
|
||||
try:
|
||||
# Start download in background
|
||||
asyncio.create_task(download_provider(provider_type))
|
||||
return {
|
||||
"message": f"Provider {provider_type} download started",
|
||||
"provider_type": provider_type,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/providers/download/progress/{provider_type}")
|
||||
async def get_provider_download_progress(provider_type: str):
|
||||
"""Get provider download progress via Server-Sent Events."""
|
||||
from fastapi.responses import StreamingResponse
|
||||
from .utils.progress import get_progress_manager
|
||||
|
||||
progress_manager = get_progress_manager()
|
||||
|
||||
async def event_generator():
|
||||
"""Generate SSE events for provider download progress."""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
last_progress = None
|
||||
|
||||
while True:
|
||||
progress = progress_manager.get_progress(provider_type)
|
||||
|
||||
if progress and progress != last_progress:
|
||||
yield f"data: {json.dumps(progress)}\n\n"
|
||||
last_progress = progress
|
||||
|
||||
if progress.get("status") in ["complete", "error"]:
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@app.delete("/providers/{provider_type}")
|
||||
async def delete_provider_endpoint(provider_type: str):
|
||||
"""Delete an installed provider."""
|
||||
from .providers.installer import delete_provider
|
||||
|
||||
if provider_type not in ["pytorch-cpu", "pytorch-cuda"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider type {provider_type} cannot be deleted"
|
||||
)
|
||||
|
||||
deleted = delete_provider(provider_type)
|
||||
|
||||
if deleted:
|
||||
return {"message": f"Provider {provider_type} deleted successfully"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Provider {provider_type} not found"
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/models/{model_name}")
|
||||
async def delete_model(model_name: str):
|
||||
"""Delete a downloaded model from the HuggingFace cache."""
|
||||
@@ -1458,9 +1748,9 @@ async def delete_model(model_name: str):
|
||||
try:
|
||||
# Check if model is loaded and unload it first
|
||||
if config["model_type"] == "tts":
|
||||
tts_model = tts.get_tts_model()
|
||||
if tts_model.is_loaded() and tts_model.model_size == config["model_size"]:
|
||||
tts.unload_tts_model()
|
||||
tts_model = await tts.get_tts_model_async()
|
||||
if tts_model.is_loaded() and getattr(tts_model, 'model_size', None) == config["model_size"]:
|
||||
tts_model.unload_model()
|
||||
elif config["model_type"] == "whisper":
|
||||
whisper_model = transcribe.get_whisper_model()
|
||||
if whisper_model.is_loaded() and whisper_model.model_size == config["model_size"]:
|
||||
@@ -1491,6 +1781,19 @@ async def delete_model(model_name: str):
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/cache/clear")
|
||||
async def clear_cache():
|
||||
"""Clear all voice prompt caches (memory and disk)."""
|
||||
try:
|
||||
deleted_count = clear_voice_prompt_cache()
|
||||
return {
|
||||
"message": f"Voice prompt cache cleared successfully",
|
||||
"files_deleted": deleted_count,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to clear cache: {str(e)}")
|
||||
|
||||
|
||||
# ============================================
|
||||
# TASK MANAGEMENT
|
||||
# ============================================
|
||||
@@ -1557,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
|
||||
# ============================================
|
||||
@@ -1564,11 +1877,12 @@ async def get_active_tasks():
|
||||
def _get_gpu_status() -> str:
|
||||
"""Get GPU availability status."""
|
||||
backend_type = get_backend_type()
|
||||
if torch.cuda.is_available():
|
||||
return f"CUDA ({torch.cuda.get_device_name(0)})"
|
||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||
return "MPS (Apple Silicon)"
|
||||
elif backend_type == "mlx":
|
||||
if TORCH_AVAILABLE and torch is not None:
|
||||
if torch.cuda.is_available():
|
||||
return f"CUDA ({torch.cuda.get_device_name(0)})"
|
||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||
return "MPS (Apple Silicon)"
|
||||
if backend_type == "mlx":
|
||||
return "Metal (Apple Silicon via MLX)"
|
||||
return "None (CPU only)"
|
||||
|
||||
@@ -1607,8 +1921,14 @@ async def shutdown_event():
|
||||
"""Run on application shutdown."""
|
||||
print("voicebox API shutting down...")
|
||||
# Unload models to free memory
|
||||
tts.unload_tts_model()
|
||||
transcribe.unload_whisper_model()
|
||||
try:
|
||||
tts.unload_tts_model()
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to unload TTS model: {e}")
|
||||
try:
|
||||
transcribe.unload_whisper_model()
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to unload Whisper model: {e}")
|
||||
|
||||
|
||||
# ============================================
|
||||
|
||||
@@ -134,6 +134,7 @@ class ModelStatus(BaseModel):
|
||||
model_name: str
|
||||
display_name: str
|
||||
downloaded: bool
|
||||
downloading: bool = False # True if download is in progress
|
||||
size_mb: Optional[float] = None
|
||||
loaded: bool = False
|
||||
|
||||
@@ -169,6 +170,13 @@ class ActiveTasksResponse(BaseModel):
|
||||
generations: List[ActiveGenerationTask]
|
||||
|
||||
|
||||
class FolderPathsResponse(BaseModel):
|
||||
"""Response model for system folder paths."""
|
||||
data_dir: str
|
||||
models_dir: str
|
||||
providers_dir: str
|
||||
|
||||
|
||||
class AudioChannelCreate(BaseModel):
|
||||
"""Request model for creating an audio channel."""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
+42
-16
@@ -22,6 +22,7 @@ from .database import (
|
||||
)
|
||||
from .utils.audio import validate_reference_audio, load_audio, save_audio
|
||||
from .utils.images import validate_image, process_avatar
|
||||
from .utils.cache import _get_cache_dir, clear_profile_cache
|
||||
from .tts import get_tts_model
|
||||
from . import config
|
||||
|
||||
@@ -120,6 +121,10 @@ async def add_profile_sample(
|
||||
db.commit()
|
||||
db.refresh(db_sample)
|
||||
|
||||
# Invalidate combined audio cache for this profile
|
||||
# Since a new sample was added, any cached combined audio is now stale
|
||||
clear_profile_cache(profile_id)
|
||||
|
||||
return ProfileSampleResponse.model_validate(db_sample)
|
||||
|
||||
|
||||
@@ -241,6 +246,9 @@ async def delete_profile(
|
||||
if profile_dir.exists():
|
||||
shutil.rmtree(profile_dir)
|
||||
|
||||
# Clean up combined audio cache files for this profile
|
||||
clear_profile_cache(profile_id)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -262,6 +270,9 @@ async def delete_profile_sample(
|
||||
if not sample:
|
||||
return False
|
||||
|
||||
# Store profile_id before deleting
|
||||
profile_id = sample.profile_id
|
||||
|
||||
# Delete audio file
|
||||
audio_path = Path(sample.audio_path)
|
||||
if audio_path.exists():
|
||||
@@ -271,6 +282,10 @@ async def delete_profile_sample(
|
||||
db.delete(sample)
|
||||
db.commit()
|
||||
|
||||
# Invalidate combined audio cache for this profile
|
||||
# Since the sample set changed, any cached combined audio is now stale
|
||||
clear_profile_cache(profile_id)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -294,10 +309,17 @@ async def update_profile_sample(
|
||||
if not sample:
|
||||
return None
|
||||
|
||||
# Store profile_id before updating
|
||||
profile_id = sample.profile_id
|
||||
|
||||
sample.reference_text = reference_text
|
||||
db.commit()
|
||||
db.refresh(sample)
|
||||
|
||||
# Invalidate combined audio cache for this profile
|
||||
# Since the reference text changed, cache keys and combined text are now stale
|
||||
clear_profile_cache(profile_id)
|
||||
|
||||
return ProfileSampleResponse.model_validate(sample)
|
||||
|
||||
|
||||
@@ -345,23 +367,27 @@ async def create_voice_prompt_for_profile(
|
||||
reference_texts,
|
||||
)
|
||||
|
||||
# Save combined audio temporarily
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
save_audio(combined_audio, tmp.name, 24000)
|
||||
tmp_path = tmp.name
|
||||
# Save combined audio to cache directory (persistent)
|
||||
# Create a hash of sample IDs to identify this specific combination
|
||||
import hashlib
|
||||
sample_ids_str = "-".join(sorted([s.id for s in samples]))
|
||||
combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12]
|
||||
|
||||
# Store in cache directory
|
||||
cache_dir = _get_cache_dir()
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav"
|
||||
|
||||
# Save combined audio
|
||||
save_audio(combined_audio, str(combined_path), 24000)
|
||||
|
||||
try:
|
||||
# Create prompt from combined audio
|
||||
voice_prompt, _ = await tts_model.create_voice_prompt(
|
||||
tmp_path,
|
||||
combined_text,
|
||||
use_cache=use_cache,
|
||||
)
|
||||
return voice_prompt
|
||||
finally:
|
||||
# Clean up temp file
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
# Create prompt from combined audio
|
||||
voice_prompt, _ = await tts_model.create_voice_prompt(
|
||||
str(combined_path),
|
||||
combined_text,
|
||||
use_cache=use_cache,
|
||||
)
|
||||
return voice_prompt
|
||||
|
||||
|
||||
async def upload_avatar(
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
Provider management system for TTS providers.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
from .base import TTSProvider
|
||||
from .types import ProviderType
|
||||
from .bundled import BundledProvider
|
||||
from .local import LocalProvider
|
||||
from .installer import get_provider_binary_path, _get_providers_dir
|
||||
from ..config import get_data_dir
|
||||
import subprocess
|
||||
import socket
|
||||
|
||||
|
||||
class ProviderManager:
|
||||
"""Manages TTS provider lifecycle."""
|
||||
|
||||
def __init__(self):
|
||||
self.active_provider: Optional[TTSProvider] = None
|
||||
self._default_provider: Optional[TTSProvider] = None
|
||||
self._provider_process: Optional[subprocess.Popen] = None
|
||||
self._provider_port: Optional[int] = None
|
||||
|
||||
def _get_default_provider(self) -> TTSProvider:
|
||||
"""Get the default bundled provider."""
|
||||
if self._default_provider is None:
|
||||
self._default_provider = BundledProvider()
|
||||
return self._default_provider
|
||||
|
||||
async def get_active_provider(self) -> TTSProvider:
|
||||
"""
|
||||
Get the currently active provider.
|
||||
|
||||
Returns:
|
||||
Active TTS provider instance
|
||||
"""
|
||||
if self.active_provider is None:
|
||||
# Default to bundled provider
|
||||
self.active_provider = self._get_default_provider()
|
||||
return self.active_provider
|
||||
|
||||
async def start_provider(self, provider_type: str) -> None:
|
||||
"""
|
||||
Start a TTS provider.
|
||||
|
||||
Args:
|
||||
provider_type: Type of provider to start
|
||||
"""
|
||||
if provider_type == "apple-mlx":
|
||||
# Use bundled MLX provider
|
||||
self.active_provider = self._get_default_provider()
|
||||
elif provider_type in ["pytorch-cpu", "pytorch-cuda"]:
|
||||
# Try to start external provider subprocess if binary exists
|
||||
provider_path = get_provider_binary_path(provider_type)
|
||||
if provider_path and provider_path.exists():
|
||||
# External downloaded provider exists, start it
|
||||
# Find a free port
|
||||
port = self._get_free_port()
|
||||
|
||||
# Start provider subprocess with stdout/stderr capture
|
||||
from ..config import get_data_dir
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info(f"Starting provider {provider_type} on port {port}")
|
||||
logger.info(f"Provider binary: {provider_path}")
|
||||
logger.info(f"Data directory: {get_data_dir()}")
|
||||
|
||||
# Create log files for provider output (easier debugging on Windows)
|
||||
logs_dir = get_data_dir() / "logs"
|
||||
logs_dir.mkdir(exist_ok=True)
|
||||
stdout_log = logs_dir / f"{provider_type}-stdout.log"
|
||||
stderr_log = logs_dir / f"{provider_type}-stderr.log"
|
||||
|
||||
logger.info(f"Provider logs will be written to: {logs_dir}")
|
||||
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
str(provider_path),
|
||||
"--port", str(port),
|
||||
"--data-dir", str(get_data_dir()),
|
||||
],
|
||||
stdout=open(stdout_log, 'w'),
|
||||
stderr=open(stderr_log, 'w'),
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
# Wait for provider to be ready
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
try:
|
||||
await self._wait_for_provider_health(base_url, timeout=30)
|
||||
except TimeoutError as e:
|
||||
# Read log files for debugging (works on all platforms unlike select)
|
||||
stdout_content = ""
|
||||
stderr_content = ""
|
||||
|
||||
# Try to read available output (works on Windows and Unix)
|
||||
try:
|
||||
# Use non-blocking read with timeout
|
||||
import threading
|
||||
import queue
|
||||
|
||||
def enqueue_output(stream, queue):
|
||||
try:
|
||||
for line in iter(stream.readline, ''):
|
||||
queue.put(line)
|
||||
except:
|
||||
pass
|
||||
|
||||
stdout_queue = queue.Queue()
|
||||
stderr_queue = queue.Queue()
|
||||
|
||||
if process.stdout:
|
||||
t = threading.Thread(target=enqueue_output, args=(process.stdout, stdout_queue))
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
if process.stderr:
|
||||
t2 = threading.Thread(target=enqueue_output, args=(process.stderr, stderr_queue))
|
||||
t2.daemon = True
|
||||
t2.start()
|
||||
|
||||
# Give threads a moment to read
|
||||
import time
|
||||
time.sleep(0.5)
|
||||
|
||||
# Collect output
|
||||
while not stdout_queue.empty():
|
||||
stdout_lines.append(stdout_queue.get_nowait())
|
||||
while not stderr_queue.empty():
|
||||
stderr_lines.append(stderr_queue.get_nowait())
|
||||
except Exception as ex:
|
||||
logger.warning(f"Could not capture subprocess output: {ex}")
|
||||
|
||||
logger.error(f"Provider failed to start within 30 seconds")
|
||||
logger.error(f"Check logs at: {logs_dir}")
|
||||
if stdout_content:
|
||||
logger.error(f"Stdout: {stdout_content[-2000:]}") # Last 2000 chars
|
||||
if stderr_content:
|
||||
logger.error(f"Stderr: {stderr_content[-2000:]}") # Last 2000 chars
|
||||
|
||||
# Terminate the process
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
# Raise with log file location for user
|
||||
raise TimeoutError(
|
||||
f"Provider {provider_type} failed to start. Check logs at: {logs_dir}"
|
||||
)
|
||||
|
||||
# Create LocalProvider instance
|
||||
self.active_provider = LocalProvider(base_url)
|
||||
self._provider_process = process
|
||||
self._provider_port = port
|
||||
|
||||
# Logs are written directly to files (stdout_log, stderr_log)
|
||||
# No need for background task - users can check {logs_dir} for debugging
|
||||
else:
|
||||
# No external binary, use bundled provider (if available)
|
||||
if provider_type == "pytorch-cpu":
|
||||
# PyTorch CPU can use bundled backend
|
||||
self.active_provider = self._get_default_provider()
|
||||
else:
|
||||
raise ValueError(f"Provider {provider_type} is not installed. Please download it first.")
|
||||
elif provider_type == "remote":
|
||||
# Remote provider - will be implemented in Phase 5
|
||||
raise NotImplementedError("Remote provider not yet implemented")
|
||||
elif provider_type == "openai":
|
||||
# OpenAI provider - will be implemented in Phase 5
|
||||
raise NotImplementedError("OpenAI provider not yet implemented")
|
||||
else:
|
||||
raise ValueError(f"Unknown provider type: {provider_type}")
|
||||
|
||||
async def stop_provider(self) -> None:
|
||||
"""Stop the active provider."""
|
||||
if self.active_provider:
|
||||
# Only stop if it's not the default bundled provider
|
||||
if self.active_provider is not self._default_provider:
|
||||
if hasattr(self.active_provider, 'stop'):
|
||||
await self.active_provider.stop()
|
||||
self.active_provider = None
|
||||
|
||||
# Stop subprocess if running
|
||||
if self._provider_process:
|
||||
self._provider_process.terminate()
|
||||
try:
|
||||
self._provider_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._provider_process.kill()
|
||||
self._provider_process = None
|
||||
self._provider_port = None
|
||||
|
||||
async def list_installed(self) -> list[str]:
|
||||
"""
|
||||
List installed provider types.
|
||||
|
||||
Returns:
|
||||
List of installed provider type strings
|
||||
"""
|
||||
installed = []
|
||||
|
||||
# Bundled providers are always available
|
||||
system = platform.system()
|
||||
machine = platform.machine()
|
||||
|
||||
if system == "Darwin" and machine == "arm64":
|
||||
# Apple Silicon gets MLX bundled
|
||||
installed.append("apple-mlx")
|
||||
elif system == "Windows" or (system == "Darwin" and machine != "arm64"):
|
||||
# Windows and Intel macOS get PyTorch CPU bundled
|
||||
installed.append("pytorch-cpu")
|
||||
# Linux: no bundled provider - users must download
|
||||
|
||||
# Check for downloaded providers by checking if binary path exists
|
||||
for provider_type in ["pytorch-cpu", "pytorch-cuda"]:
|
||||
binary_path = get_provider_binary_path(provider_type)
|
||||
if binary_path and binary_path.exists() and provider_type not in installed:
|
||||
installed.append(provider_type)
|
||||
|
||||
return installed
|
||||
|
||||
async def get_provider_info(self, provider_type: str) -> dict:
|
||||
"""
|
||||
Get information about a provider.
|
||||
|
||||
Args:
|
||||
provider_type: Type of provider
|
||||
|
||||
Returns:
|
||||
Provider information dictionary
|
||||
"""
|
||||
if provider_type in ["apple-mlx", "bundled-pytorch"]:
|
||||
return {
|
||||
"type": provider_type,
|
||||
"name": "Bundled Provider",
|
||||
"installed": True,
|
||||
"size_mb": None, # Bundled, no separate size
|
||||
}
|
||||
elif provider_type == "pytorch-cpu":
|
||||
return {
|
||||
"type": provider_type,
|
||||
"name": "PyTorch CPU",
|
||||
"installed": provider_type in await self.list_installed(),
|
||||
"size_mb": 300,
|
||||
}
|
||||
elif provider_type == "pytorch-cuda":
|
||||
return {
|
||||
"type": provider_type,
|
||||
"name": "PyTorch CUDA",
|
||||
"installed": provider_type in await self.list_installed(),
|
||||
"size_mb": 2400,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"type": provider_type,
|
||||
"name": provider_type,
|
||||
"installed": False,
|
||||
"size_mb": None,
|
||||
}
|
||||
|
||||
|
||||
def _get_free_port(self) -> int:
|
||||
"""Get a free port for the provider server."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('', 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
async def _wait_for_provider_health(self, base_url: str, timeout: int = 30) -> None:
|
||||
"""Wait for provider to become healthy."""
|
||||
import httpx
|
||||
import asyncio
|
||||
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
while True:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
response = await client.get(f"{base_url}/tts/health")
|
||||
if response.status_code == 200:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if asyncio.get_event_loop().time() - start_time > timeout:
|
||||
raise TimeoutError(f"Provider did not become healthy within {timeout} seconds")
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
async def _log_subprocess_output(self, process: subprocess.Popen) -> None:
|
||||
"""Log subprocess stdout and stderr."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def read_stream(stream, prefix):
|
||||
if stream:
|
||||
loop = asyncio.get_event_loop()
|
||||
while True:
|
||||
line = await loop.run_in_executor(None, stream.readline)
|
||||
if not line:
|
||||
break
|
||||
logger.info(f"{prefix}: {line.rstrip()}")
|
||||
|
||||
await asyncio.gather(
|
||||
read_stream(process.stdout, "Provider stdout"),
|
||||
read_stream(process.stderr, "Provider stderr"),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
|
||||
# Global provider manager instance
|
||||
_provider_manager: Optional[ProviderManager] = None
|
||||
|
||||
|
||||
def get_provider_manager() -> ProviderManager:
|
||||
"""Get the global provider manager instance."""
|
||||
global _provider_manager
|
||||
if _provider_manager is None:
|
||||
_provider_manager = ProviderManager()
|
||||
return _provider_manager
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Base protocol for TTS providers.
|
||||
"""
|
||||
|
||||
from typing import Protocol, Optional, Tuple
|
||||
from typing_extensions import runtime_checkable
|
||||
import numpy as np
|
||||
|
||||
from .types import ProviderHealth, ProviderStatus
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TTSProvider(Protocol):
|
||||
"""Protocol for TTS provider implementations."""
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str = "en",
|
||||
seed: Optional[int] = None,
|
||||
instruct: Optional[str] = None,
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
"""
|
||||
Generate speech audio from text.
|
||||
|
||||
Args:
|
||||
text: Text to synthesize
|
||||
voice_prompt: Voice prompt dictionary
|
||||
language: Language code
|
||||
seed: Random seed for reproducibility
|
||||
instruct: Delivery instructions
|
||||
|
||||
Returns:
|
||||
Tuple of (audio_array, sample_rate)
|
||||
"""
|
||||
...
|
||||
|
||||
async def create_voice_prompt(
|
||||
self,
|
||||
audio_path: str,
|
||||
reference_text: str,
|
||||
use_cache: bool = True,
|
||||
) -> Tuple[dict, bool]:
|
||||
"""
|
||||
Create voice prompt from reference audio.
|
||||
|
||||
Args:
|
||||
audio_path: Path to reference audio file
|
||||
reference_text: Transcript of the audio
|
||||
use_cache: Whether to use cached prompts
|
||||
|
||||
Returns:
|
||||
Tuple of (voice_prompt_dict, was_cached)
|
||||
"""
|
||||
...
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: list[str],
|
||||
reference_texts: list[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
"""
|
||||
Combine multiple voice prompts.
|
||||
|
||||
Args:
|
||||
audio_paths: List of audio file paths
|
||||
reference_texts: List of reference texts
|
||||
|
||||
Returns:
|
||||
Tuple of (combined_audio_array, combined_text)
|
||||
"""
|
||||
...
|
||||
|
||||
async def load_model_async(self, model_size: str) -> None:
|
||||
"""Load TTS model."""
|
||||
...
|
||||
|
||||
def unload_model(self) -> None:
|
||||
"""Unload model to free memory."""
|
||||
...
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if model is loaded."""
|
||||
...
|
||||
|
||||
def _get_model_path(self, model_size: str) -> str:
|
||||
"""Get model path for a given size."""
|
||||
...
|
||||
|
||||
async def health(self) -> ProviderHealth:
|
||||
"""Get provider health status."""
|
||||
...
|
||||
|
||||
async def status(self) -> ProviderStatus:
|
||||
"""Get provider model status."""
|
||||
...
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Bundled provider that wraps existing MLX/PyTorch backends.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
import numpy as np
|
||||
import platform
|
||||
|
||||
from .base import TTSProvider
|
||||
from .types import ProviderHealth, ProviderStatus
|
||||
from ..backends import get_tts_backend, TTSBackend
|
||||
from ..platform_detect import get_backend_type
|
||||
|
||||
|
||||
class BundledProvider:
|
||||
"""Provider that wraps the existing bundled TTS backend."""
|
||||
|
||||
def __init__(self):
|
||||
self._backend: Optional[TTSBackend] = None
|
||||
|
||||
def _get_backend(self) -> TTSBackend:
|
||||
"""Get or create backend instance."""
|
||||
if self._backend is None:
|
||||
self._backend = get_tts_backend()
|
||||
return self._backend
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str = "en",
|
||||
seed: Optional[int] = None,
|
||||
instruct: Optional[str] = None,
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
"""Generate speech audio."""
|
||||
backend = self._get_backend()
|
||||
return await backend.generate(text, voice_prompt, language, seed, instruct)
|
||||
|
||||
async def create_voice_prompt(
|
||||
self,
|
||||
audio_path: str,
|
||||
reference_text: str,
|
||||
use_cache: bool = True,
|
||||
) -> Tuple[dict, bool]:
|
||||
"""Create voice prompt from reference audio."""
|
||||
backend = self._get_backend()
|
||||
return await backend.create_voice_prompt(audio_path, reference_text, use_cache)
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: list[str],
|
||||
reference_texts: list[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
"""Combine multiple voice prompts."""
|
||||
backend = self._get_backend()
|
||||
return await backend.combine_voice_prompts(audio_paths, reference_texts)
|
||||
|
||||
async def load_model_async(self, model_size: str) -> None:
|
||||
"""Load TTS model."""
|
||||
backend = self._get_backend()
|
||||
if hasattr(backend, 'load_model_async'):
|
||||
await backend.load_model_async(model_size)
|
||||
else:
|
||||
await backend.load_model(model_size)
|
||||
|
||||
# Alias for compatibility
|
||||
load_model = load_model_async
|
||||
|
||||
def unload_model(self) -> None:
|
||||
"""Unload model to free memory."""
|
||||
backend = self._get_backend()
|
||||
backend.unload_model()
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if model is loaded."""
|
||||
backend = self._get_backend()
|
||||
return backend.is_loaded()
|
||||
|
||||
def _get_model_path(self, model_size: str) -> str:
|
||||
"""Get model path for a given size."""
|
||||
backend = self._get_backend()
|
||||
return backend._get_model_path(model_size)
|
||||
|
||||
async def health(self) -> ProviderHealth:
|
||||
"""Get provider health status."""
|
||||
backend = self._get_backend()
|
||||
backend_type = get_backend_type()
|
||||
|
||||
model_size = None
|
||||
if backend.is_loaded():
|
||||
# Try to get current model size from backend
|
||||
if hasattr(backend, '_current_model_size') and backend._current_model_size:
|
||||
model_size = backend._current_model_size
|
||||
|
||||
device = None
|
||||
if backend_type == "mlx":
|
||||
device = "metal"
|
||||
elif hasattr(backend, 'device'):
|
||||
device = backend.device
|
||||
|
||||
# Use apple-mlx for MLX backend, pytorch-cpu for PyTorch
|
||||
provider_name = "apple-mlx" if backend_type == "mlx" else "pytorch-cpu"
|
||||
|
||||
return ProviderHealth(
|
||||
status="healthy",
|
||||
provider=provider_name,
|
||||
version=None, # Provider versioning not implemented yet
|
||||
model=model_size,
|
||||
device=device,
|
||||
)
|
||||
|
||||
async def status(self) -> ProviderStatus:
|
||||
"""Get provider model status."""
|
||||
backend = self._get_backend()
|
||||
backend_type = get_backend_type()
|
||||
|
||||
model_size = None
|
||||
if backend.is_loaded():
|
||||
if hasattr(backend, '_current_model_size') and backend._current_model_size:
|
||||
model_size = backend._current_model_size
|
||||
|
||||
available_sizes = ["1.7B"]
|
||||
if backend_type == "pytorch":
|
||||
available_sizes.append("0.6B")
|
||||
|
||||
gpu_available = None
|
||||
vram_used_mb = None
|
||||
|
||||
if backend_type == "pytorch":
|
||||
try:
|
||||
import torch
|
||||
gpu_available = torch.cuda.is_available()
|
||||
if gpu_available:
|
||||
vram_used_mb = torch.cuda.memory_allocated() / 1024 / 1024
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return ProviderStatus(
|
||||
model_loaded=backend.is_loaded(),
|
||||
model_size=model_size,
|
||||
available_sizes=available_sizes,
|
||||
gpu_available=gpu_available,
|
||||
vram_used_mb=int(vram_used_mb) if vram_used_mb else None,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
# Provider checksums - embedded at build time for security
|
||||
# This file is auto-generated during CI builds
|
||||
# In development, checksums are empty (verification is skipped)
|
||||
|
||||
PROVIDER_CHECKSUMS = {
|
||||
# Populated during release builds with SHA256 checksums of provider binaries
|
||||
# Example:
|
||||
# "tts-provider-pytorch-cpu-windows.exe": "abc123...",
|
||||
# "tts-provider-pytorch-cuda-windows.exe": "def456...",
|
||||
# "tts-provider-pytorch-cuda-linux": "789xyz...",
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
Provider download and installation manager.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .types import ProviderType
|
||||
from ..utils.progress import get_progress_manager
|
||||
from ..utils.tasks import get_task_manager
|
||||
|
||||
|
||||
# Provider version (independent of app version)
|
||||
PROVIDER_VERSION = "1.0.0"
|
||||
|
||||
# Base URL for provider downloads (Cloudflare R2)
|
||||
PROVIDER_DOWNLOAD_BASE_URL = "https://downloads.voicebox.sh/providers"
|
||||
|
||||
|
||||
def _get_providers_dir() -> Path:
|
||||
"""Get the directory where providers are stored."""
|
||||
system = platform.system()
|
||||
|
||||
if system == "Windows":
|
||||
appdata = Path.home() / "AppData" / "Roaming"
|
||||
elif system == "Darwin":
|
||||
appdata = Path.home() / "Library" / "Application Support"
|
||||
else: # Linux
|
||||
appdata = Path.home() / ".local" / "share"
|
||||
|
||||
providers_dir = appdata / "voicebox" / "providers"
|
||||
providers_dir.mkdir(parents=True, exist_ok=True)
|
||||
return providers_dir
|
||||
|
||||
|
||||
def _get_provider_binary_name(provider_type: str) -> str:
|
||||
"""Get the local binary filename for a provider type."""
|
||||
system = platform.system()
|
||||
ext = ".exe" if system == "Windows" else ""
|
||||
|
||||
binary_map = {
|
||||
"pytorch-cpu": f"tts-provider-pytorch-cpu{ext}",
|
||||
"pytorch-cuda": f"tts-provider-pytorch-cuda{ext}",
|
||||
}
|
||||
|
||||
if provider_type not in binary_map:
|
||||
raise ValueError(f"Unknown provider type: {provider_type}")
|
||||
|
||||
return binary_map[provider_type]
|
||||
|
||||
|
||||
def _get_provider_download_name(provider_type: str) -> str:
|
||||
"""Get the remote download filename for a provider type (includes platform suffix)."""
|
||||
system = platform.system()
|
||||
|
||||
if system == "Windows":
|
||||
platform_suffix = "windows"
|
||||
ext = ".zip"
|
||||
elif system == "Linux":
|
||||
platform_suffix = "linux"
|
||||
ext = ".tar.gz"
|
||||
elif system == "Darwin":
|
||||
# Detect macOS architecture
|
||||
machine = platform.machine()
|
||||
if machine == "arm64":
|
||||
platform_suffix = "macos-arm64"
|
||||
else:
|
||||
platform_suffix = "macos-x64"
|
||||
ext = ".tar.gz"
|
||||
else:
|
||||
raise ValueError(f"Provider downloads not supported on {system}")
|
||||
|
||||
return f"tts-provider-{provider_type}-{platform_suffix}{ext}"
|
||||
|
||||
|
||||
def _get_provider_download_url(provider_type: str) -> str:
|
||||
"""Get the download URL for a provider."""
|
||||
download_name = _get_provider_download_name(provider_type)
|
||||
return f"{PROVIDER_DOWNLOAD_BASE_URL}/v{PROVIDER_VERSION}/{download_name}"
|
||||
|
||||
|
||||
async def download_provider(provider_type: str) -> Path:
|
||||
"""
|
||||
Download and extract a provider archive from Cloudflare R2.
|
||||
|
||||
Args:
|
||||
provider_type: Type of provider to download (e.g., "pytorch-cpu")
|
||||
|
||||
Returns:
|
||||
Path to the extracted provider binary
|
||||
|
||||
Raises:
|
||||
ValueError: If provider_type is invalid
|
||||
httpx.HTTPError: If download fails
|
||||
"""
|
||||
if provider_type not in ["pytorch-cpu", "pytorch-cuda"]:
|
||||
raise ValueError(f"Provider type {provider_type} cannot be downloaded")
|
||||
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
|
||||
archive_name = _get_provider_download_name(provider_type)
|
||||
download_url = _get_provider_download_url(provider_type)
|
||||
providers_dir = _get_providers_dir()
|
||||
archive_path = providers_dir / archive_name
|
||||
|
||||
# Start tracking download
|
||||
task_manager.start_download(provider_type)
|
||||
|
||||
# Initialize progress state
|
||||
progress_manager.update_progress(
|
||||
model_name=provider_type,
|
||||
current=0,
|
||||
total=0, # Will be updated once we get Content-Length
|
||||
filename=archive_name,
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
try:
|
||||
# Download archive
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
async with client.stream("GET", download_url) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
# Get total size from Content-Length header
|
||||
total_size = int(response.headers.get("Content-Length", 0))
|
||||
|
||||
if total_size > 0:
|
||||
progress_manager.update_progress(
|
||||
model_name=provider_type,
|
||||
current=0,
|
||||
total=total_size,
|
||||
filename=archive_name,
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Download with progress tracking
|
||||
downloaded = 0
|
||||
with open(archive_path, "wb") as f:
|
||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
# Update progress
|
||||
progress_manager.update_progress(
|
||||
model_name=provider_type,
|
||||
current=downloaded,
|
||||
total=total_size if total_size > 0 else downloaded,
|
||||
filename=archive_name,
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Extract archive
|
||||
progress_manager.update_progress(
|
||||
model_name=provider_type,
|
||||
current=downloaded,
|
||||
total=downloaded,
|
||||
filename="Extracting...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
import zipfile
|
||||
import tarfile
|
||||
|
||||
if archive_name.endswith('.zip'):
|
||||
with zipfile.ZipFile(archive_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(providers_dir)
|
||||
elif archive_name.endswith('.tar.gz'):
|
||||
with tarfile.open(archive_path, 'r:gz') as tar_ref:
|
||||
tar_ref.extractall(providers_dir)
|
||||
else:
|
||||
raise ValueError(f"Unsupported archive format: {archive_name}")
|
||||
|
||||
# Remove archive after extraction
|
||||
archive_path.unlink()
|
||||
|
||||
# Get path to extracted binary
|
||||
binary_path = get_provider_binary_path(provider_type)
|
||||
if not binary_path:
|
||||
raise ValueError(f"Provider binary not found after extraction")
|
||||
|
||||
# Make executable on Unix systems
|
||||
if platform.system() != "Windows":
|
||||
binary_path.chmod(0o755)
|
||||
|
||||
# Mark as complete
|
||||
progress_manager.update_progress(
|
||||
model_name=provider_type,
|
||||
current=downloaded,
|
||||
total=downloaded,
|
||||
filename=_get_provider_binary_name(provider_type),
|
||||
status="complete",
|
||||
)
|
||||
task_manager.complete_download(provider_type)
|
||||
|
||||
return binary_path
|
||||
|
||||
except Exception as e:
|
||||
# Clean up archive if it exists
|
||||
if archive_path.exists():
|
||||
archive_path.unlink()
|
||||
|
||||
# Mark as error
|
||||
progress_manager.update_progress(
|
||||
model_name=provider_type,
|
||||
current=0,
|
||||
total=0,
|
||||
filename=archive_name,
|
||||
status="error",
|
||||
)
|
||||
task_manager.error_download(provider_type, str(e))
|
||||
raise
|
||||
|
||||
|
||||
def get_provider_binary_path(provider_type: str) -> Optional[Path]:
|
||||
"""
|
||||
Get the path to an installed provider binary.
|
||||
|
||||
Args:
|
||||
provider_type: Type of provider
|
||||
|
||||
Returns:
|
||||
Path to provider binary, or None if not installed
|
||||
"""
|
||||
providers_dir = _get_providers_dir()
|
||||
binary_name = _get_provider_binary_name(provider_type)
|
||||
|
||||
# Check for --onedir structure (directory with binary inside)
|
||||
provider_dir = providers_dir / f"tts-provider-{provider_type}"
|
||||
if provider_dir.exists() and provider_dir.is_dir():
|
||||
binary_path = provider_dir / binary_name
|
||||
if binary_path.exists() and binary_path.is_file():
|
||||
return binary_path
|
||||
|
||||
# Fallback: check for direct binary (legacy)
|
||||
provider_path = providers_dir / binary_name
|
||||
if provider_path.exists() and provider_path.is_file():
|
||||
return provider_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def delete_provider(provider_type: str) -> bool:
|
||||
"""
|
||||
Delete an installed provider binary.
|
||||
|
||||
Args:
|
||||
provider_type: Type of provider to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
provider_path = get_provider_binary_path(provider_type)
|
||||
|
||||
if provider_path and provider_path.exists():
|
||||
provider_path.unlink()
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Local provider that communicates with standalone provider servers via HTTP.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
import base64
|
||||
import io
|
||||
import numpy as np
|
||||
import httpx
|
||||
import soundfile as sf
|
||||
|
||||
from .base import TTSProvider
|
||||
from .types import ProviderHealth, ProviderStatus
|
||||
|
||||
|
||||
class LocalProvider:
|
||||
"""Provider that communicates with local subprocess via HTTP."""
|
||||
|
||||
def __init__(self, base_url: str):
|
||||
"""
|
||||
Initialize local provider.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the provider server (e.g., "http://localhost:8000")
|
||||
"""
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.client = httpx.AsyncClient(timeout=300.0) # 5 minute timeout for generation
|
||||
self._current_model_size = "1.7B" # Default model size
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str = "en",
|
||||
seed: Optional[int] = None,
|
||||
instruct: Optional[str] = None,
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
"""Generate speech audio."""
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/tts/generate",
|
||||
json={
|
||||
"text": text,
|
||||
"voice_prompt": voice_prompt,
|
||||
"language": language,
|
||||
"seed": seed,
|
||||
"model_size": self._current_model_size,
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Decode base64 audio
|
||||
audio_bytes = base64.b64decode(data["audio"])
|
||||
audio_buffer = io.BytesIO(audio_bytes)
|
||||
audio, sample_rate = sf.read(audio_buffer)
|
||||
|
||||
return audio, data["sample_rate"]
|
||||
|
||||
async def create_voice_prompt(
|
||||
self,
|
||||
audio_path: str,
|
||||
reference_text: str,
|
||||
use_cache: bool = True,
|
||||
) -> Tuple[dict, bool]:
|
||||
"""Create voice prompt from reference audio."""
|
||||
# Read audio file
|
||||
with open(audio_path, 'rb') as f:
|
||||
audio_data = f.read()
|
||||
|
||||
# Send multipart form data
|
||||
files = {
|
||||
"audio": ("audio.wav", audio_data, "audio/wav")
|
||||
}
|
||||
data = {
|
||||
"reference_text": reference_text,
|
||||
"use_cache": str(use_cache).lower(),
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/tts/create_voice_prompt",
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
return result["voice_prompt"], result.get("was_cached", False)
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: list[str],
|
||||
reference_texts: list[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
"""
|
||||
Combine multiple voice prompts.
|
||||
|
||||
Note: This is not implemented in the provider API yet.
|
||||
For now, we'll combine locally by concatenating audio.
|
||||
"""
|
||||
import numpy as np
|
||||
from ..utils.audio import load_audio, normalize_audio
|
||||
|
||||
combined_audio = []
|
||||
for audio_path in audio_paths:
|
||||
audio, sr = load_audio(audio_path)
|
||||
audio = normalize_audio(audio)
|
||||
combined_audio.append(audio)
|
||||
|
||||
# Concatenate audio
|
||||
mixed = np.concatenate(combined_audio)
|
||||
mixed = normalize_audio(mixed)
|
||||
|
||||
# Combine texts
|
||||
combined_text = " ".join(reference_texts)
|
||||
|
||||
return mixed, combined_text
|
||||
|
||||
async def load_model_async(self, model_size: str) -> None:
|
||||
"""Load TTS model."""
|
||||
# Track the requested model size - the provider server will load it
|
||||
# when generate() is called with this size
|
||||
self._current_model_size = model_size
|
||||
|
||||
# Alias for compatibility
|
||||
load_model = load_model_async
|
||||
|
||||
def unload_model(self) -> None:
|
||||
"""Unload model to free memory."""
|
||||
# Model unloading is handled by the provider server
|
||||
# This is a no-op for local providers
|
||||
pass
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if model is loaded."""
|
||||
# We can't know this without querying the provider
|
||||
# Return True optimistically
|
||||
return True
|
||||
|
||||
def _get_model_path(self, model_size: str) -> str:
|
||||
"""Get model path for a given size."""
|
||||
# For local providers, model paths are handled by the provider server
|
||||
# Return a placeholder
|
||||
return f"Qwen/Qwen3-TTS-12Hz-{model_size}-Base"
|
||||
|
||||
async def health(self) -> ProviderHealth:
|
||||
"""Get provider health status."""
|
||||
try:
|
||||
response = await self.client.get(f"{self.base_url}/tts/health")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return ProviderHealth(
|
||||
status=data["status"],
|
||||
provider=data["provider"],
|
||||
version=data.get("version"),
|
||||
model=data.get("model"),
|
||||
device=data.get("device"),
|
||||
)
|
||||
except Exception as e:
|
||||
return ProviderHealth(
|
||||
status="unhealthy",
|
||||
provider="local",
|
||||
version=None,
|
||||
model=None,
|
||||
device=None,
|
||||
)
|
||||
|
||||
async def status(self) -> ProviderStatus:
|
||||
"""Get provider model status."""
|
||||
try:
|
||||
response = await self.client.get(f"{self.base_url}/tts/status")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return ProviderStatus(
|
||||
model_loaded=data["model_loaded"],
|
||||
model_size=data.get("model_size"),
|
||||
available_sizes=data.get("available_sizes", []),
|
||||
gpu_available=data.get("gpu_available"),
|
||||
vram_used_mb=data.get("vram_used_mb"),
|
||||
)
|
||||
except Exception as e:
|
||||
return ProviderStatus(
|
||||
model_loaded=False,
|
||||
model_size=None,
|
||||
available_sizes=[],
|
||||
gpu_available=None,
|
||||
vram_used_mb=None,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the provider (close HTTP client)."""
|
||||
await self.client.aclose()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Shared types for TTS providers.
|
||||
"""
|
||||
|
||||
from typing import Optional, TypedDict
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ProviderType(str, Enum):
|
||||
"""Available provider types."""
|
||||
BUNDLED_MLX = "apple-mlx"
|
||||
BUNDLED_PYTORCH = "bundled-pytorch"
|
||||
PYTORCH_CPU = "pytorch-cpu"
|
||||
PYTORCH_CUDA = "pytorch-cuda"
|
||||
REMOTE = "remote"
|
||||
OPENAI = "openai"
|
||||
|
||||
|
||||
class ProviderHealth(TypedDict):
|
||||
"""Provider health status."""
|
||||
status: str # "healthy", "unhealthy", "starting"
|
||||
provider: str
|
||||
version: Optional[str]
|
||||
model: Optional[str]
|
||||
device: Optional[str]
|
||||
|
||||
|
||||
class ProviderStatus(TypedDict):
|
||||
"""Provider model status."""
|
||||
model_loaded: bool
|
||||
model_size: Optional[str]
|
||||
available_sizes: list[str]
|
||||
gpu_available: Optional[bool]
|
||||
vram_used_mb: Optional[int]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Backend Tests
|
||||
|
||||
Manual test scripts for debugging and validating backend functionality.
|
||||
|
||||
## Test Files
|
||||
|
||||
### `test_generation_progress.py`
|
||||
Tests TTS generation with SSE progress monitoring to identify UX issues where users see download progress even when the model is already cached.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend
|
||||
python tests/test_generation_progress.py
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Server must be running (`python main.py`)
|
||||
- At least one voice profile must exist
|
||||
|
||||
### `test_real_download.py`
|
||||
Tests real model download with SSE progress monitoring.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend
|
||||
# Delete cache first to force fresh download
|
||||
rm -rf ~/.cache/huggingface/hub/models--openai--whisper-base
|
||||
python tests/test_real_download.py
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Server must be running (`python main.py`)
|
||||
|
||||
### `test_progress.py`
|
||||
Unit tests for ProgressManager and HFProgressTracker functionality.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend
|
||||
python tests/test_progress.py
|
||||
```
|
||||
|
||||
### `test_check_progress_state.py`
|
||||
Debugging script to inspect the internal state of ProgressManager and TaskManager.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd backend
|
||||
python tests/test_check_progress_state.py
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
These are manual test scripts, not automated unit tests. They're designed for:
|
||||
- Debugging progress tracking issues
|
||||
- Validating SSE event streams
|
||||
- Monitoring real-time download behavior
|
||||
- Inspecting internal state during development
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Test suite for Voicebox backend.
|
||||
|
||||
This directory contains manual test scripts for debugging and validating
|
||||
progress tracking, model downloads, and generation functionality.
|
||||
"""
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
Test TTS generation with SSE progress monitoring.
|
||||
This test captures the exact SSE events triggered during generation
|
||||
to identify UX issues where users see download progress even when
|
||||
the model is already cached.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 120):
|
||||
"""Monitor SSE stream for a model during generation."""
|
||||
events: List[Dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
|
||||
print(f"[{_timestamp()}] Connecting to SSE endpoint: {url}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
print(f"[{_timestamp()}] SSE connected, status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"[{_timestamp()}] Error: SSE endpoint returned {response.status_code}")
|
||||
return events
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
timestamp = _timestamp()
|
||||
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
print(f"[{timestamp}] → SSE Event: {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
|
||||
events.append({
|
||||
**data,
|
||||
"_timestamp": timestamp
|
||||
})
|
||||
|
||||
# Stop if complete or error
|
||||
if data.get("status") in ("complete", "error"):
|
||||
print(f"[{timestamp}] → Model {data['status']}!")
|
||||
break
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[{timestamp}] Error parsing JSON: {e}")
|
||||
print(f" Line was: {line}")
|
||||
|
||||
elif line.startswith(": heartbeat"):
|
||||
print(f"[{timestamp}] ♥ heartbeat")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
print(f"[{_timestamp()}] SSE monitoring timed out")
|
||||
except Exception as e:
|
||||
print(f"[{_timestamp()}] SSE error: {e}")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def trigger_generation(profile_id: str, text: str, model_size: str = "1.7B"):
|
||||
"""Trigger TTS generation via the API."""
|
||||
url = "http://localhost:8000/generate"
|
||||
|
||||
print(f"\n[{_timestamp()}] Triggering generation...")
|
||||
print(f" Profile: {profile_id}")
|
||||
print(f" Text: {text[:50]}...")
|
||||
print(f" Model: {model_size}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(url, json={
|
||||
"profile_id": profile_id,
|
||||
"text": text,
|
||||
"language": "en",
|
||||
"model_size": model_size,
|
||||
})
|
||||
|
||||
print(f"[{_timestamp()}] Response: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print(f"[{_timestamp()}] ✓ Generation successful!")
|
||||
print(f" Generation ID: {result.get('id')}")
|
||||
print(f" Duration: {result.get('duration', 0):.2f}s")
|
||||
return True, result
|
||||
elif response.status_code == 202:
|
||||
# Model is being downloaded
|
||||
result = response.json()
|
||||
print(f"[{_timestamp()}] → Model download in progress")
|
||||
print(f" Detail: {result}")
|
||||
return False, result
|
||||
else:
|
||||
print(f"[{_timestamp()}] ✗ Error: {response.text}")
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[{_timestamp()}] ✗ Exception: {e}")
|
||||
return False, None
|
||||
|
||||
|
||||
async def get_first_profile():
|
||||
"""Get the first available voice profile."""
|
||||
url = "http://localhost:8000/profiles"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(url)
|
||||
if response.status_code == 200:
|
||||
profiles = response.json()
|
||||
if profiles:
|
||||
return profiles[0]["id"]
|
||||
except Exception as e:
|
||||
print(f"Error getting profiles: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def check_server():
|
||||
"""Check if the server is running."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get("http://localhost:8000/health")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
print(f"Server not running: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _timestamp():
|
||||
"""Get current timestamp for logging."""
|
||||
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
|
||||
|
||||
async def test_generation_with_cached_model():
|
||||
"""
|
||||
Test Case 1: Generation when model is already cached.
|
||||
|
||||
This should NOT show any download progress events.
|
||||
If it does, that's the UX bug we're trying to fix.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("TEST CASE 1: Generation with Cached Model")
|
||||
print("=" * 80)
|
||||
print("Expected: No download progress events (or minimal/instant completion)")
|
||||
print("Actual UX Issue: Users see 'started' and 'finished' events even for cached models")
|
||||
print("=" * 80)
|
||||
|
||||
model_size = "1.7B"
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
# Get a profile
|
||||
profile_id = await get_first_profile()
|
||||
if not profile_id:
|
||||
print("✗ No voice profiles found. Please create a profile first.")
|
||||
return False
|
||||
|
||||
print(f"\nUsing profile: {profile_id}")
|
||||
|
||||
# Start SSE monitor BEFORE triggering generation
|
||||
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=30))
|
||||
|
||||
# Wait for SSE to connect
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Trigger generation
|
||||
test_text = "Hello, this is a test of the voice generation system."
|
||||
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||
|
||||
if not success and result and result.get("downloading"):
|
||||
print("\n⚠ Model is being downloaded. Waiting for download to complete...")
|
||||
# Wait for SSE monitor to capture download events
|
||||
events = await monitor_task
|
||||
return events
|
||||
|
||||
# Wait a bit more to catch any progress events
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Cancel SSE monitor
|
||||
monitor_task.cancel()
|
||||
try:
|
||||
events = await monitor_task
|
||||
except asyncio.CancelledError:
|
||||
events = []
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def test_generation_with_fresh_download():
|
||||
"""
|
||||
Test Case 2: Generation when model needs to be downloaded.
|
||||
|
||||
This SHOULD show download progress events.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("TEST CASE 2: Generation with Model Download")
|
||||
print("=" * 80)
|
||||
print("Expected: Download progress events from 0% to 100%")
|
||||
print("=" * 80)
|
||||
|
||||
# Use a different model size to force download
|
||||
model_size = "0.6B" # Smaller model for faster testing
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
# Get a profile
|
||||
profile_id = await get_first_profile()
|
||||
if not profile_id:
|
||||
print("✗ No voice profiles found. Please create a profile first.")
|
||||
return False
|
||||
|
||||
print(f"\nUsing profile: {profile_id}")
|
||||
print("Note: This will download the model if not cached")
|
||||
|
||||
# Start SSE monitor BEFORE triggering generation
|
||||
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=300))
|
||||
|
||||
# Wait for SSE to connect
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Trigger generation
|
||||
test_text = "This should trigger a model download if the model is not cached."
|
||||
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||
|
||||
if not success and result and result.get("downloading"):
|
||||
print("\n→ Model download initiated. Monitoring progress...")
|
||||
# Wait for download to complete
|
||||
events = await monitor_task
|
||||
|
||||
# Try generation again
|
||||
print(f"\n[{_timestamp()}] Retrying generation after download...")
|
||||
await asyncio.sleep(2)
|
||||
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||
|
||||
if success:
|
||||
print("✓ Generation successful after download")
|
||||
|
||||
return events
|
||||
|
||||
# If model was already cached
|
||||
await asyncio.sleep(3)
|
||||
monitor_task.cancel()
|
||||
try:
|
||||
events = await monitor_task
|
||||
except asyncio.CancelledError:
|
||||
events = []
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 80)
|
||||
print("TTS Generation Progress Test")
|
||||
print("=" * 80)
|
||||
print("Purpose: Capture exact SSE events during generation to identify UX issues")
|
||||
print("=" * 80)
|
||||
|
||||
# Check if server is running
|
||||
print(f"\n[{_timestamp()}] Checking if server is running...")
|
||||
if not await check_server():
|
||||
print("✗ Server is not running on http://localhost:8000")
|
||||
print("\nPlease start the server first:")
|
||||
print(" cd backend && python main.py")
|
||||
return False
|
||||
|
||||
print("✓ Server is running")
|
||||
|
||||
# Test Case 1: Cached model
|
||||
print("\n" + "🧪 " * 20)
|
||||
events_cached = await test_generation_with_cached_model()
|
||||
|
||||
# Results for Test Case 1
|
||||
print("\n" + "=" * 80)
|
||||
print("TEST CASE 1 RESULTS: Generation with Cached Model")
|
||||
print("=" * 80)
|
||||
|
||||
if not events_cached:
|
||||
print("✓ GOOD: No SSE progress events received")
|
||||
print(" This is the expected behavior for a cached model.")
|
||||
else:
|
||||
print(f"⚠ ISSUE FOUND: Received {len(events_cached)} SSE events:")
|
||||
print("\nEvent Timeline:")
|
||||
for i, event in enumerate(events_cached, 1):
|
||||
timestamp = event.pop("_timestamp", "??:??:??.???")
|
||||
print(f" {i}. [{timestamp}] {event}")
|
||||
|
||||
print("\n⚠ This explains the UX issue!")
|
||||
print(" Users see progress events even when the model is already cached,")
|
||||
print(" making them think the model is downloading again.")
|
||||
|
||||
# Test Case 2: Fresh download (optional, commented out by default)
|
||||
# Uncomment if you want to test download progress
|
||||
# print("\n" + "🧪 " * 20)
|
||||
# events_download = await test_generation_with_fresh_download()
|
||||
#
|
||||
# print("\n" + "=" * 80)
|
||||
# print("TEST CASE 2 RESULTS: Generation with Model Download")
|
||||
# print("=" * 80)
|
||||
#
|
||||
# if not events_download:
|
||||
# print("ℹ Model was already cached, no download occurred")
|
||||
# else:
|
||||
# print(f"✓ Received {len(events_download)} download progress events")
|
||||
# print("\nDownload Timeline:")
|
||||
# for i, event in enumerate(events_download, 1):
|
||||
# timestamp = event.pop("_timestamp", "??:??:??.???")
|
||||
# print(f" {i}. [{timestamp}] {event}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("Test Complete!")
|
||||
print("=" * 80)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Test script to debug model download progress tracking.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import List, Dict
|
||||
import logging
|
||||
|
||||
# Set up logging to see what's happening
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
from utils.progress import ProgressManager, get_progress_manager
|
||||
from utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||
|
||||
|
||||
def test_progress_manager_basic():
|
||||
"""Test 1: Basic ProgressManager functionality."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 1: ProgressManager Basic Operations")
|
||||
print("=" * 60)
|
||||
|
||||
pm = ProgressManager()
|
||||
|
||||
# Test update_progress
|
||||
pm.update_progress(
|
||||
model_name="test-model",
|
||||
current=50,
|
||||
total=100,
|
||||
filename="test.bin",
|
||||
status="downloading"
|
||||
)
|
||||
|
||||
# Test get_progress
|
||||
progress = pm.get_progress("test-model")
|
||||
print(f"✓ Progress stored: {progress}")
|
||||
assert progress is not None
|
||||
assert progress["progress"] == 50.0
|
||||
assert progress["filename"] == "test.bin"
|
||||
assert progress["status"] == "downloading"
|
||||
|
||||
# Test mark_complete
|
||||
pm.mark_complete("test-model")
|
||||
progress = pm.get_progress("test-model")
|
||||
print(f"✓ Marked complete: {progress}")
|
||||
assert progress["status"] == "complete"
|
||||
assert progress["progress"] == 100.0
|
||||
|
||||
print("✓ Test 1 PASSED\n")
|
||||
return True
|
||||
|
||||
|
||||
async def test_progress_manager_sse():
|
||||
"""Test 2: ProgressManager SSE streaming."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 2: ProgressManager SSE Streaming")
|
||||
print("=" * 60)
|
||||
|
||||
pm = ProgressManager()
|
||||
collected_events: List[Dict] = []
|
||||
|
||||
# Simulate SSE client
|
||||
async def sse_client():
|
||||
"""Simulates a frontend SSE connection."""
|
||||
print(" SSE client: Subscribing to test-model-sse...")
|
||||
async for event in pm.subscribe("test-model-sse"):
|
||||
# Parse SSE event
|
||||
if event.startswith("data: "):
|
||||
data = json.loads(event[6:])
|
||||
print(f" SSE client: Received event: {data['status']} - {data.get('progress', 0):.1f}%")
|
||||
collected_events.append(data)
|
||||
|
||||
# Stop when complete
|
||||
if data.get("status") in ("complete", "error"):
|
||||
break
|
||||
elif event.startswith(": heartbeat"):
|
||||
print(" SSE client: Received heartbeat")
|
||||
|
||||
# Simulate download progress updates (from backend thread)
|
||||
async def simulate_download():
|
||||
"""Simulates backend sending progress updates."""
|
||||
print(" Backend: Starting simulated download...")
|
||||
await asyncio.sleep(0.2) # Let SSE client subscribe first
|
||||
|
||||
# Send progress updates
|
||||
for i in range(0, 101, 20):
|
||||
print(f" Backend: Updating progress to {i}%")
|
||||
pm.update_progress(
|
||||
model_name="test-model-sse",
|
||||
current=i,
|
||||
total=100,
|
||||
filename=f"file_{i}.bin",
|
||||
status="downloading" if i < 100 else "downloading"
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Mark complete
|
||||
print(" Backend: Marking download complete")
|
||||
pm.mark_complete("test-model-sse")
|
||||
|
||||
# Run SSE client and download simulation concurrently
|
||||
await asyncio.gather(
|
||||
sse_client(),
|
||||
simulate_download()
|
||||
)
|
||||
|
||||
# Verify we got events
|
||||
print(f"\n Collected {len(collected_events)} events")
|
||||
assert len(collected_events) > 0, "Should have received at least one event"
|
||||
assert collected_events[-1]["status"] == "complete", "Last event should be 'complete'"
|
||||
|
||||
print("✓ Test 2 PASSED\n")
|
||||
return True
|
||||
|
||||
|
||||
def test_hf_progress_tracker():
|
||||
"""Test 3: HFProgressTracker tqdm patching."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 3: HFProgressTracker tqdm Patching")
|
||||
print("=" * 60)
|
||||
|
||||
captured_progress: List[tuple] = []
|
||||
|
||||
def progress_callback(downloaded: int, total: int, filename: str):
|
||||
"""Capture progress updates."""
|
||||
captured_progress.append((downloaded, total, filename))
|
||||
print(f" Progress callback: {downloaded}/{total} bytes ({filename})")
|
||||
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
|
||||
# Simulate a download with tqdm
|
||||
with tracker.patch_download():
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
|
||||
# Simulate downloading a file
|
||||
print(" Simulating download with tqdm...")
|
||||
total_size = 1000
|
||||
with tqdm(total=total_size, desc="model.bin", unit="B", unit_scale=True) as pbar:
|
||||
for chunk in range(0, total_size, 100):
|
||||
pbar.update(100)
|
||||
time.sleep(0.01)
|
||||
|
||||
print(f" Captured {len(captured_progress)} progress updates")
|
||||
assert len(captured_progress) > 0, "Should have captured progress updates"
|
||||
|
||||
# Verify progress increases
|
||||
last_downloaded = 0
|
||||
for downloaded, total, filename in captured_progress:
|
||||
assert downloaded >= last_downloaded, "Downloaded bytes should increase"
|
||||
assert total == total_size, "Total should be consistent"
|
||||
last_downloaded = downloaded
|
||||
|
||||
print("✓ Test 3 PASSED\n")
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
print("✗ tqdm not available, skipping test\n")
|
||||
return None
|
||||
|
||||
|
||||
async def test_full_integration():
|
||||
"""Test 4: Full integration test."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 4: Full Integration (ProgressManager + HFProgressTracker)")
|
||||
print("=" * 60)
|
||||
|
||||
pm = get_progress_manager()
|
||||
collected_events: List[Dict] = []
|
||||
|
||||
# SSE client
|
||||
async def sse_client():
|
||||
print(" SSE client: Subscribing...")
|
||||
async for event in pm.subscribe("integration-test"):
|
||||
if event.startswith("data: "):
|
||||
data = json.loads(event[6:])
|
||||
print(f" SSE client: {data['status']} - {data.get('progress', 0):.1f}% - {data.get('filename', '')}")
|
||||
collected_events.append(data)
|
||||
if data.get("status") in ("complete", "error"):
|
||||
break
|
||||
|
||||
# Simulate backend download with HFProgressTracker
|
||||
async def simulate_real_download():
|
||||
await asyncio.sleep(0.2) # Let SSE subscribe
|
||||
|
||||
print(" Backend: Starting download with HFProgressTracker...")
|
||||
|
||||
# Set up tracking (like the real backend does)
|
||||
progress_callback = create_hf_progress_callback("integration-test", pm)
|
||||
tracker = HFProgressTracker(progress_callback)
|
||||
|
||||
# Initialize progress
|
||||
pm.update_progress(
|
||||
model_name="integration-test",
|
||||
current=0,
|
||||
total=1,
|
||||
filename="",
|
||||
status="downloading"
|
||||
)
|
||||
|
||||
# Simulate download with tqdm patching
|
||||
with tracker.patch_download():
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
|
||||
# Simulate multi-file download (like HuggingFace does)
|
||||
files = [
|
||||
("model.safetensors", 5000),
|
||||
("config.json", 1000),
|
||||
("tokenizer.json", 500),
|
||||
]
|
||||
|
||||
for filename, size in files:
|
||||
print(f" Backend: Downloading {filename}...")
|
||||
with tqdm(total=size, desc=filename, unit="B") as pbar:
|
||||
for chunk in range(0, size, 500):
|
||||
chunk_size = min(500, size - chunk)
|
||||
pbar.update(chunk_size)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# Mark complete
|
||||
print(" Backend: Download complete")
|
||||
pm.mark_complete("integration-test")
|
||||
|
||||
except ImportError:
|
||||
print(" ✗ tqdm not available")
|
||||
pm.mark_error("integration-test", "tqdm not available")
|
||||
|
||||
# Run both
|
||||
await asyncio.gather(
|
||||
sse_client(),
|
||||
simulate_real_download()
|
||||
)
|
||||
|
||||
# Verify
|
||||
print(f"\n Collected {len(collected_events)} events")
|
||||
if len(collected_events) > 0:
|
||||
print(f" First event: {collected_events[0]}")
|
||||
print(f" Last event: {collected_events[-1]}")
|
||||
assert collected_events[-1]["status"] == "complete", "Should end with 'complete'"
|
||||
print("✓ Test 4 PASSED\n")
|
||||
return True
|
||||
else:
|
||||
print("✗ Test 4 FAILED - No events received\n")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all tests."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Voicebox Progress Tracking Test Suite")
|
||||
print("=" * 60)
|
||||
|
||||
results = []
|
||||
|
||||
# Test 1: Basic operations
|
||||
try:
|
||||
results.append(("Basic Operations", test_progress_manager_basic()))
|
||||
except Exception as e:
|
||||
print(f"✗ Test 1 FAILED: {e}\n")
|
||||
results.append(("Basic Operations", False))
|
||||
|
||||
# Test 2: SSE streaming
|
||||
try:
|
||||
results.append(("SSE Streaming", await test_progress_manager_sse()))
|
||||
except Exception as e:
|
||||
print(f"✗ Test 2 FAILED: {e}\n")
|
||||
results.append(("SSE Streaming", False))
|
||||
|
||||
# Test 3: tqdm patching
|
||||
try:
|
||||
results.append(("tqdm Patching", test_hf_progress_tracker()))
|
||||
except Exception as e:
|
||||
print(f"✗ Test 3 FAILED: {e}\n")
|
||||
results.append(("tqdm Patching", False))
|
||||
|
||||
# Test 4: Full integration
|
||||
try:
|
||||
results.append(("Full Integration", await test_full_integration()))
|
||||
except Exception as e:
|
||||
print(f"✗ Test 4 FAILED: {e}\n")
|
||||
results.append(("Full Integration", False))
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("Test Results Summary")
|
||||
print("=" * 60)
|
||||
|
||||
for name, result in results:
|
||||
status = "✓ PASS" if result else ("⊘ SKIP" if result is None else "✗ FAIL")
|
||||
print(f" {status:8} {name}")
|
||||
|
||||
passed = sum(1 for _, r in results if r is True)
|
||||
failed = sum(1 for _, r in results if r is False)
|
||||
skipped = sum(1 for _, r in results if r is None)
|
||||
|
||||
print()
|
||||
print(f" Total: {len(results)} tests")
|
||||
print(f" Passed: {passed}")
|
||||
print(f" Failed: {failed}")
|
||||
print(f" Skipped: {skipped}")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
return failed == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = asyncio.run(main())
|
||||
exit(0 if success else 1)
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Test Qwen TTS model download with SSE progress monitoring.
|
||||
|
||||
This specifically tests the MLX TTS backend download progress tracking,
|
||||
which requires tqdm to be patched BEFORE mlx_audio is imported.
|
||||
|
||||
Usage:
|
||||
cd backend && python -m tests.test_qwen_download
|
||||
|
||||
Prerequisites:
|
||||
- Server must be running: cd backend && python main.py
|
||||
- Delete model first for fresh download test:
|
||||
curl -X DELETE http://localhost:8000/models/qwen-tts-0.6B
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
import time
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 600) -> List[Dict]:
|
||||
"""
|
||||
Monitor SSE stream for a model download.
|
||||
|
||||
Args:
|
||||
model_name: Name of the model to monitor
|
||||
timeout: Maximum time to wait for download (seconds)
|
||||
|
||||
Returns:
|
||||
List of SSE events received
|
||||
"""
|
||||
events: List[Dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
last_progress = -1
|
||||
|
||||
print(f"\n📡 Connecting to SSE endpoint: {url}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
print(f" SSE connected, status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f" ❌ Error: SSE endpoint returned {response.status_code}")
|
||||
return events
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
events.append(data)
|
||||
|
||||
# Print progress (only when it changes significantly)
|
||||
progress = data.get('progress', 0)
|
||||
status = data.get('status', 'unknown')
|
||||
filename = data.get('filename', '')
|
||||
current = data.get('current', 0)
|
||||
total = data.get('total', 0)
|
||||
|
||||
# Print every 5% change or status change
|
||||
if abs(progress - last_progress) >= 5 or status in ('complete', 'error'):
|
||||
current_mb = current / (1024 * 1024)
|
||||
total_mb = total / (1024 * 1024)
|
||||
print(f" 📊 {status:12} {progress:6.1f}% ({current_mb:.1f}MB / {total_mb:.1f}MB) {filename[:50]}")
|
||||
last_progress = progress
|
||||
|
||||
# Stop if complete or error
|
||||
if status in ("complete", "error"):
|
||||
if status == "complete":
|
||||
print(f" ✅ Download complete!")
|
||||
else:
|
||||
print(f" ❌ Download error: {data.get('error', 'unknown')}")
|
||||
break
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" ⚠️ Error parsing JSON: {e}")
|
||||
|
||||
elif line.startswith(": heartbeat"):
|
||||
# Heartbeat every 1 second, don't spam
|
||||
pass
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print(" ⏹️ SSE monitor cancelled")
|
||||
except Exception as e:
|
||||
print(f" ❌ SSE error: {e}")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def trigger_download(model_name: str) -> bool:
|
||||
"""Trigger a model download via the API."""
|
||||
url = "http://localhost:8000/models/download"
|
||||
|
||||
print(f"\n🚀 Triggering download for: {model_name}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(url, json={"model_name": model_name})
|
||||
result = response.json()
|
||||
print(f" Response: {response.status_code} - {result}")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
print(f" ❌ Error triggering download: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def delete_model(model_name: str) -> bool:
|
||||
"""Delete a model from cache."""
|
||||
url = f"http://localhost:8000/models/{model_name}"
|
||||
|
||||
print(f"\n🗑️ Deleting model: {model_name}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.delete(url)
|
||||
if response.status_code == 200:
|
||||
print(f" ✅ Model deleted")
|
||||
return True
|
||||
elif response.status_code == 404:
|
||||
print(f" ℹ️ Model not found (already deleted)")
|
||||
return True
|
||||
else:
|
||||
print(f" ⚠️ Delete response: {response.status_code} - {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ❌ Error deleting model: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def check_model_status(model_name: str) -> Optional[Dict]:
|
||||
"""Check the status of a model."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get("http://localhost:8000/models/status")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
for model in data.get("models", []):
|
||||
if model["model_name"] == model_name:
|
||||
return model
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Error checking model status: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def check_server() -> bool:
|
||||
"""Check if the server is running."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get("http://localhost:8000/health")
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 70)
|
||||
print("🧪 Qwen TTS Model Download Progress Test")
|
||||
print("=" * 70)
|
||||
print("\nThis test verifies that MLX TTS download progress tracking works.")
|
||||
print("It specifically tests the tqdm patching for mlx_audio.tts imports.")
|
||||
|
||||
# Check if server is running
|
||||
print("\n📡 Checking if server is running...")
|
||||
if not await check_server():
|
||||
print(" ❌ Server is not running on http://localhost:8000")
|
||||
print("\n Please start the server first:")
|
||||
print(" cd backend && python main.py")
|
||||
return False
|
||||
|
||||
print(" ✅ Server is running")
|
||||
|
||||
# Test model
|
||||
model_name = "qwen-tts-0.6B" # Note: 0.6B currently maps to 1.7B on MLX
|
||||
|
||||
# Check current status
|
||||
print(f"\n📊 Checking status of {model_name}...")
|
||||
status = await check_model_status(model_name)
|
||||
if status:
|
||||
print(f" Downloaded: {status.get('downloaded', False)}")
|
||||
print(f" Downloading: {status.get('downloading', False)}")
|
||||
print(f" Loaded: {status.get('loaded', False)}")
|
||||
if status.get('size_mb'):
|
||||
print(f" Size: {status['size_mb']:.1f} MB")
|
||||
else:
|
||||
print(" ⚠️ Could not get model status")
|
||||
|
||||
# Ask if user wants to delete first
|
||||
print("\n" + "-" * 70)
|
||||
if status and status.get('downloaded'):
|
||||
print("⚠️ Model is already downloaded. Delete it for a fresh download test?")
|
||||
print(" [y] Yes, delete and download fresh")
|
||||
print(" [n] No, just test SSE connection")
|
||||
print(" [q] Quit")
|
||||
|
||||
choice = input("\nChoice [y/n/q]: ").strip().lower()
|
||||
|
||||
if choice == 'q':
|
||||
print("Exiting...")
|
||||
return True
|
||||
|
||||
if choice == 'y':
|
||||
if not await delete_model(model_name):
|
||||
print("Failed to delete model. Continue anyway? [y/n]")
|
||||
if input().strip().lower() != 'y':
|
||||
return False
|
||||
else:
|
||||
print("Model not downloaded. Will perform fresh download test.")
|
||||
input("Press Enter to continue...")
|
||||
|
||||
# Run the test
|
||||
print("\n" + "=" * 70)
|
||||
print("🏃 Starting Download Test")
|
||||
print("=" * 70)
|
||||
|
||||
async def run_test():
|
||||
# Start SSE monitor in background FIRST
|
||||
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=600))
|
||||
|
||||
# Wait for SSE to connect
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Trigger download
|
||||
success = await trigger_download(model_name)
|
||||
|
||||
if not success:
|
||||
print(" ❌ Failed to trigger download")
|
||||
monitor_task.cancel()
|
||||
try:
|
||||
await monitor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return []
|
||||
|
||||
# Wait for SSE monitor to complete
|
||||
print("\n⏳ Waiting for download to complete (this may take several minutes)...")
|
||||
events = await monitor_task
|
||||
|
||||
return events
|
||||
|
||||
start_time = time.time()
|
||||
events = await run_test()
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Results
|
||||
print("\n" + "=" * 70)
|
||||
print("📋 Test Results")
|
||||
print("=" * 70)
|
||||
|
||||
print(f"\n⏱️ Elapsed time: {elapsed:.1f} seconds")
|
||||
print(f"📨 Total SSE events received: {len(events)}")
|
||||
|
||||
if not events:
|
||||
print("\n❌ FAILED - No SSE events received!")
|
||||
print("\nPossible causes:")
|
||||
print(" 1. SSE endpoint not working")
|
||||
print(" 2. tqdm not patched before mlx_audio import")
|
||||
print(" 3. Progress callbacks not firing")
|
||||
print(" 4. Model already fully downloaded")
|
||||
print("\nDebug steps:")
|
||||
print(" 1. Check server logs for [DEBUG] messages")
|
||||
print(" 2. Look for 'tqdm patched' before 'mlx_audio.tts import'")
|
||||
print(f" 3. Delete model: curl -X DELETE http://localhost:8000/models/{model_name}")
|
||||
return False
|
||||
|
||||
# Analyze events
|
||||
first_event = events[0]
|
||||
last_event = events[-1]
|
||||
|
||||
print(f"\n📊 First event:")
|
||||
print(f" Status: {first_event.get('status')}")
|
||||
print(f" Progress: {first_event.get('progress', 0):.1f}%")
|
||||
|
||||
print(f"\n📊 Last event:")
|
||||
print(f" Status: {last_event.get('status')}")
|
||||
print(f" Progress: {last_event.get('progress', 0):.1f}%")
|
||||
|
||||
# Check for expected behaviors
|
||||
has_progress_updates = len(events) > 2
|
||||
has_increasing_progress = False
|
||||
has_complete = any(e.get('status') == 'complete' for e in events)
|
||||
has_100_percent = any(e.get('progress', 0) >= 100 for e in events)
|
||||
|
||||
# Check if progress increased over time
|
||||
if len(events) >= 2:
|
||||
progress_values = [e.get('progress', 0) for e in events]
|
||||
has_increasing_progress = progress_values[-1] > progress_values[0]
|
||||
|
||||
print("\n📋 Checks:")
|
||||
print(f" {'✅' if has_progress_updates else '❌'} Multiple progress updates received ({len(events)} events)")
|
||||
print(f" {'✅' if has_increasing_progress else '❌'} Progress increased over time")
|
||||
print(f" {'✅' if has_100_percent else '❌'} Reached 100% progress")
|
||||
print(f" {'✅' if has_complete else '❌'} Received 'complete' status")
|
||||
|
||||
# Overall result
|
||||
success = has_progress_updates and has_complete
|
||||
|
||||
if success:
|
||||
print("\n" + "=" * 70)
|
||||
print("✅ TEST PASSED - Qwen TTS download progress tracking works!")
|
||||
print("=" * 70)
|
||||
else:
|
||||
print("\n" + "=" * 70)
|
||||
print("❌ TEST FAILED - Progress tracking has issues")
|
||||
print("=" * 70)
|
||||
print("\nCheck the server logs for debug output.")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = asyncio.run(main())
|
||||
exit(0 if result else 1)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Test real model download with SSE progress monitoring.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
import time
|
||||
from typing import List, Dict
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 300):
|
||||
"""Monitor SSE stream for a model download."""
|
||||
events: List[Dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
|
||||
print(f"Connecting to SSE endpoint: {url}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
print(f"SSE connected, status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"Error: SSE endpoint returned {response.status_code}")
|
||||
return events
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
print(f" Raw SSE: {line[:100]}...") # Print first 100 chars
|
||||
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
print(f" → {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
|
||||
events.append(data)
|
||||
|
||||
# Stop if complete or error
|
||||
if data.get("status") in ("complete", "error"):
|
||||
print(f" Download {data['status']}!")
|
||||
break
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" Error parsing JSON: {e}")
|
||||
print(f" Line was: {line}")
|
||||
|
||||
elif line.startswith(": heartbeat"):
|
||||
print(" ♥ heartbeat")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def trigger_download(model_name: str):
|
||||
"""Trigger a model download via the API."""
|
||||
url = "http://localhost:8000/models/download"
|
||||
|
||||
print(f"\nTriggering download for: {model_name}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=300) as client:
|
||||
response = await client.post(url, json={"model_name": model_name})
|
||||
print(f"Response: {response.status_code} - {response.json()}")
|
||||
return response.status_code == 200
|
||||
|
||||
|
||||
async def check_server():
|
||||
"""Check if the server is running."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get("http://localhost:8000/health")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
print(f"Server not running: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 60)
|
||||
print("Real Model Download Progress Test")
|
||||
print("=" * 60)
|
||||
|
||||
# Check if server is running
|
||||
print("\nChecking if server is running...")
|
||||
if not await check_server():
|
||||
print("✗ Server is not running on http://localhost:8000")
|
||||
print("\nPlease start the server first:")
|
||||
print(" cd backend && python main.py")
|
||||
return False
|
||||
|
||||
print("✓ Server is running")
|
||||
|
||||
# Choose a small model for testing
|
||||
model_name = "whisper-base" # ~150MB, faster to download
|
||||
print(f"\nUsing model: {model_name}")
|
||||
|
||||
# Option to delete model first if it exists
|
||||
print("\nDo you want to delete the model first to force a fresh download? (y/n)")
|
||||
# For automated testing, skip deletion prompt
|
||||
# delete_first = input().strip().lower() == 'y'
|
||||
delete_first = False
|
||||
|
||||
if delete_first:
|
||||
print(f"Deleting {model_name}...")
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.delete(f"http://localhost:8000/models/{model_name}")
|
||||
print(f"Delete response: {response.status_code}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Starting Test")
|
||||
print("=" * 60)
|
||||
|
||||
# Start monitoring SSE stream BEFORE triggering download
|
||||
async def run_test():
|
||||
# Start SSE monitor in background
|
||||
monitor_task = asyncio.create_task(monitor_sse_stream(model_name))
|
||||
|
||||
# Wait a bit to ensure SSE is connected
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Trigger download
|
||||
success = await trigger_download(model_name)
|
||||
|
||||
if not success:
|
||||
print("✗ Failed to trigger download")
|
||||
monitor_task.cancel()
|
||||
return False
|
||||
|
||||
# Wait for SSE monitor to complete
|
||||
events = await monitor_task
|
||||
|
||||
return events
|
||||
|
||||
events = await run_test()
|
||||
|
||||
# Results
|
||||
print("\n" + "=" * 60)
|
||||
print("Test Results")
|
||||
print("=" * 60)
|
||||
|
||||
if not events:
|
||||
print("✗ FAILED - No SSE events received!")
|
||||
print("\nPossible causes:")
|
||||
print(" 1. SSE endpoint not working")
|
||||
print(" 2. Progress updates not being sent")
|
||||
print(" 3. Model already downloaded (no progress to report)")
|
||||
print("\nTry deleting the model first to force a fresh download:")
|
||||
print(f" curl -X DELETE http://localhost:8000/models/{model_name}")
|
||||
return False
|
||||
|
||||
print(f"✓ Received {len(events)} SSE events")
|
||||
print(f"\nFirst event: {events[0]}")
|
||||
print(f"Last event: {events[-1]}")
|
||||
|
||||
# Check if we got meaningful progress
|
||||
has_progress = any(e.get('progress', 0) > 0 for e in events)
|
||||
has_complete = any(e.get('status') == 'complete' for e in events)
|
||||
|
||||
if has_progress:
|
||||
print("✓ Progress updates received")
|
||||
else:
|
||||
print("✗ No progress updates (might be already downloaded)")
|
||||
|
||||
if has_complete:
|
||||
print("✓ Download completed successfully")
|
||||
else:
|
||||
print("✗ Download did not complete")
|
||||
|
||||
success = has_progress and has_complete
|
||||
|
||||
if success:
|
||||
print("\n✓ TEST PASSED - Progress tracking works!")
|
||||
else:
|
||||
print("\n⊘ TEST INCONCLUSIVE - Try with a fresh download")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+36
-16
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
TTS inference module - delegates to backend abstraction layer.
|
||||
TTS inference module - delegates to provider abstraction layer.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
@@ -7,31 +7,51 @@ import numpy as np
|
||||
import io
|
||||
import soundfile as sf
|
||||
|
||||
from .backends import get_tts_backend, TTSBackend
|
||||
from .backends import TTSBackend
|
||||
from .providers import get_provider_manager
|
||||
from .providers.base import TTSProvider
|
||||
|
||||
|
||||
def get_tts_model() -> TTSBackend:
|
||||
def get_tts_model() -> TTSProvider:
|
||||
"""
|
||||
Get TTS backend instance (MLX or PyTorch based on platform).
|
||||
Get TTS provider instance (via ProviderManager).
|
||||
|
||||
Returns:
|
||||
TTS backend instance
|
||||
TTS provider instance
|
||||
"""
|
||||
return get_tts_backend()
|
||||
manager = get_provider_manager()
|
||||
# Note: This is async but we need sync interface for backward compatibility
|
||||
# In practice, this will be called from async contexts
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# We're in an async context, but can't await here
|
||||
# Return a wrapper that will use the provider manager
|
||||
return manager._get_default_provider()
|
||||
else:
|
||||
return loop.run_until_complete(manager.get_active_provider())
|
||||
except RuntimeError:
|
||||
# No event loop, return default
|
||||
return manager._get_default_provider()
|
||||
|
||||
|
||||
async def get_tts_model_async() -> TTSProvider:
|
||||
"""
|
||||
Get TTS provider instance asynchronously.
|
||||
|
||||
Returns:
|
||||
TTS provider instance
|
||||
"""
|
||||
manager = get_provider_manager()
|
||||
return await manager.get_active_provider()
|
||||
|
||||
|
||||
def unload_tts_model():
|
||||
"""Unload TTS model to free memory."""
|
||||
backend = get_tts_backend()
|
||||
backend.unload_model()
|
||||
|
||||
|
||||
def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
|
||||
"""Convert audio array to WAV bytes."""
|
||||
buffer = io.BytesIO()
|
||||
sf.write(buffer, audio, sample_rate, format="WAV")
|
||||
buffer.seek(0)
|
||||
return buffer.read()
|
||||
manager = get_provider_manager()
|
||||
provider = manager._get_default_provider()
|
||||
provider.unload_model()
|
||||
|
||||
|
||||
def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
|
||||
|
||||
@@ -88,3 +88,63 @@ def cache_voice_prompt(
|
||||
# Store on disk (torch.save can handle both dicts and tensors)
|
||||
cache_file = _get_cache_dir() / f"{cache_key}.prompt"
|
||||
torch.save(voice_prompt, cache_file)
|
||||
|
||||
|
||||
def clear_voice_prompt_cache() -> int:
|
||||
"""
|
||||
Clear all voice prompt caches (memory and disk).
|
||||
|
||||
Returns:
|
||||
Number of cache files deleted
|
||||
"""
|
||||
# Clear memory cache
|
||||
_memory_cache.clear()
|
||||
|
||||
# Clear disk cache
|
||||
cache_dir = _get_cache_dir()
|
||||
deleted_count = 0
|
||||
|
||||
if cache_dir.exists():
|
||||
# Delete prompt cache files
|
||||
for cache_file in cache_dir.glob("*.prompt"):
|
||||
try:
|
||||
cache_file.unlink()
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"Failed to delete cache file {cache_file}: {e}")
|
||||
|
||||
# Delete combined audio files
|
||||
for audio_file in cache_dir.glob("combined_*.wav"):
|
||||
try:
|
||||
audio_file.unlink()
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"Failed to delete combined audio file {audio_file}: {e}")
|
||||
|
||||
return deleted_count
|
||||
|
||||
|
||||
def clear_profile_cache(profile_id: str) -> int:
|
||||
"""
|
||||
Clear cache files for a specific profile.
|
||||
|
||||
Args:
|
||||
profile_id: Profile ID
|
||||
|
||||
Returns:
|
||||
Number of cache files deleted
|
||||
"""
|
||||
cache_dir = _get_cache_dir()
|
||||
deleted_count = 0
|
||||
|
||||
if cache_dir.exists():
|
||||
# Delete combined audio files for this profile
|
||||
pattern = f"combined_{profile_id}_*.wav"
|
||||
for audio_file in cache_dir.glob(pattern):
|
||||
try:
|
||||
audio_file.unlink()
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"Failed to delete combined audio file {audio_file}: {e}")
|
||||
|
||||
return deleted_count
|
||||
|
||||
+159
-26
@@ -11,8 +11,9 @@ import sys
|
||||
class HFProgressTracker:
|
||||
"""Tracks HuggingFace Hub download progress by intercepting tqdm."""
|
||||
|
||||
def __init__(self, progress_callback: Optional[Callable] = None):
|
||||
def __init__(self, progress_callback: Optional[Callable] = None, filter_non_downloads: bool = False):
|
||||
self.progress_callback = progress_callback
|
||||
self.filter_non_downloads = filter_non_downloads # Only filter if True
|
||||
self._original_tqdm_class = None
|
||||
self._lock = threading.Lock()
|
||||
self._total_downloaded = 0
|
||||
@@ -21,6 +22,7 @@ class HFProgressTracker:
|
||||
self._file_downloaded = {} # Track downloaded bytes per file
|
||||
self._current_filename = ""
|
||||
self._active_tqdms = {} # Track active tqdm instances
|
||||
self._hf_tqdm_original_update = None # For monkey-patching hf's tqdm
|
||||
|
||||
def _create_tracked_tqdm_class(self):
|
||||
"""Create a tqdm subclass that tracks progress."""
|
||||
@@ -29,7 +31,7 @@ class HFProgressTracker:
|
||||
|
||||
class TrackedTqdm(original_tqdm):
|
||||
"""A tqdm subclass that reports progress to our tracker."""
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Extract filename from desc before passing to parent
|
||||
desc = kwargs.get("desc", "")
|
||||
@@ -80,7 +82,7 @@ class HFProgressTracker:
|
||||
|
||||
def update(self, n=1):
|
||||
result = super().update(n)
|
||||
|
||||
|
||||
# Report progress
|
||||
with tracker._lock:
|
||||
if id(self) in tracker._active_tqdms:
|
||||
@@ -89,6 +91,16 @@ class HFProgressTracker:
|
||||
total = getattr(self, "total", 0)
|
||||
|
||||
if total and total > 0:
|
||||
# Always filter out non-byte progress bars (e.g., "Fetching 12 files")
|
||||
# These cause crazy percentages because they're counting files, not bytes
|
||||
if self._is_non_byte_progress(filename):
|
||||
return result
|
||||
|
||||
# When model is cached, also filter out generation-related progress
|
||||
if tracker.filter_non_downloads:
|
||||
if not self._is_download_progress(filename):
|
||||
return result
|
||||
|
||||
# Update per-file tracking
|
||||
tracker._file_sizes[filename] = total
|
||||
tracker._file_downloaded[filename] = current
|
||||
@@ -97,6 +109,13 @@ class HFProgressTracker:
|
||||
tracker._total_size = sum(tracker._file_sizes.values())
|
||||
tracker._total_downloaded = sum(tracker._file_downloaded.values())
|
||||
|
||||
# Only report progress once we have a meaningful total (at least 1MB)
|
||||
# This avoids the "100% at 0MB" issue when small config
|
||||
# files are counted before the real model files
|
||||
MIN_TOTAL_BYTES = 1_000_000 # 1MB
|
||||
if tracker._total_size < MIN_TOTAL_BYTES:
|
||||
return result
|
||||
|
||||
# Call progress callback
|
||||
if tracker.progress_callback:
|
||||
tracker.progress_callback(
|
||||
@@ -107,6 +126,50 @@ class HFProgressTracker:
|
||||
|
||||
return result
|
||||
|
||||
def _is_non_byte_progress(self, filename: str) -> bool:
|
||||
"""Check if this progress bar should be SKIPPED (returns True to skip).
|
||||
|
||||
We want to track byte-based progress bars. This method identifies
|
||||
progress bars that count files/items instead of bytes, which would
|
||||
cause crazy percentages if mixed with our byte counting.
|
||||
|
||||
Returns:
|
||||
True = SKIP this bar (it's not byte-based)
|
||||
False = TRACK this bar (it counts bytes)
|
||||
"""
|
||||
if not filename:
|
||||
return False
|
||||
|
||||
filename_lower = filename.lower()
|
||||
|
||||
# Skip "Fetching X files" - it counts files (total=12), not bytes
|
||||
# Don't skip "Downloading (incomplete total...)" - that IS byte-based
|
||||
skip_patterns = [
|
||||
'fetching', # "Fetching 12 files" has total=12 files, not bytes
|
||||
]
|
||||
return any(pattern in filename_lower for pattern in skip_patterns)
|
||||
|
||||
def _is_download_progress(self, filename: str) -> bool:
|
||||
"""Check if this is a real file download progress bar vs internal processing."""
|
||||
if not filename or filename == "unknown":
|
||||
return False
|
||||
|
||||
# Real downloads have file extensions
|
||||
download_extensions = [
|
||||
'.safetensors', '.bin', '.pt', '.pth', # Model weights
|
||||
'.json', '.txt', '.py', # Config files
|
||||
'.msgpack', '.h5', # Other formats
|
||||
]
|
||||
|
||||
filename_lower = filename.lower()
|
||||
has_extension = any(filename_lower.endswith(ext) for ext in download_extensions)
|
||||
|
||||
# Skip generation-related progress indicators
|
||||
skip_patterns = ['segment', 'processing', 'generating', 'loading']
|
||||
has_skip_pattern = any(pattern in filename_lower for pattern in skip_patterns)
|
||||
|
||||
return has_extension and not has_skip_pattern
|
||||
|
||||
def close(self):
|
||||
with tracker._lock:
|
||||
if id(self) in tracker._active_tqdms:
|
||||
@@ -120,7 +183,7 @@ class HFProgressTracker:
|
||||
"""Context manager to patch tqdm for progress tracking."""
|
||||
try:
|
||||
import tqdm as tqdm_module
|
||||
|
||||
|
||||
# Store original tqdm class
|
||||
self._original_tqdm_class = tqdm_module.tqdm
|
||||
|
||||
@@ -135,10 +198,10 @@ class HFProgressTracker:
|
||||
|
||||
# Create our tracked tqdm class
|
||||
tracked_tqdm = self._create_tracked_tqdm_class()
|
||||
|
||||
|
||||
# Patch tqdm.tqdm
|
||||
tqdm_module.tqdm = tracked_tqdm
|
||||
|
||||
|
||||
# Also patch tqdm.auto.tqdm if it exists (used by huggingface_hub)
|
||||
self._original_tqdm_auto = None
|
||||
if hasattr(tqdm_module, "auto") and hasattr(tqdm_module.auto, "tqdm"):
|
||||
@@ -146,22 +209,79 @@ class HFProgressTracker:
|
||||
tqdm_module.auto.tqdm = tracked_tqdm
|
||||
|
||||
# Patch in sys.modules to catch already-imported references
|
||||
# huggingface_hub uses: from tqdm.auto import tqdm as base_tqdm
|
||||
# So we need to patch both 'tqdm' and 'base_tqdm' attributes
|
||||
self._patched_modules = {}
|
||||
tqdm_attr_names = ['tqdm', 'base_tqdm', 'old_tqdm'] # Various names used
|
||||
|
||||
patched_count = 0
|
||||
for module_name in list(sys.modules.keys()):
|
||||
if "huggingface" in module_name or module_name.startswith("tqdm"):
|
||||
try:
|
||||
module = sys.modules[module_name]
|
||||
if hasattr(module, "tqdm"):
|
||||
attr = getattr(module, "tqdm")
|
||||
# Only patch if it's the original tqdm class (not already patched)
|
||||
if attr is self._original_tqdm_class or (
|
||||
hasattr(attr, "__name__") and attr.__name__ == "tqdm"
|
||||
):
|
||||
self._patched_modules[module_name] = attr
|
||||
setattr(module, "tqdm", tracked_tqdm)
|
||||
for attr_name in tqdm_attr_names:
|
||||
if hasattr(module, attr_name):
|
||||
attr = getattr(module, attr_name)
|
||||
# Only patch if it's a tqdm class (not already patched)
|
||||
is_tqdm_class = (
|
||||
attr is self._original_tqdm_class or
|
||||
(self._original_tqdm_auto and attr is self._original_tqdm_auto) or
|
||||
(hasattr(attr, "__name__") and attr.__name__ == "tqdm" and
|
||||
hasattr(attr, "update")) # tqdm classes have update method
|
||||
)
|
||||
if is_tqdm_class:
|
||||
key = f"{module_name}.{attr_name}"
|
||||
self._patched_modules[key] = (module, attr_name, attr)
|
||||
setattr(module, attr_name, tracked_tqdm)
|
||||
patched_count += 1
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
# ALSO monkey-patch the update method on huggingface_hub's tqdm class
|
||||
# This is needed because the class was already defined at import time
|
||||
self._hf_tqdm_original_update = None
|
||||
try:
|
||||
from huggingface_hub.utils import tqdm as hf_tqdm_module
|
||||
if hasattr(hf_tqdm_module, 'tqdm'):
|
||||
hf_tqdm_class = hf_tqdm_module.tqdm
|
||||
self._hf_tqdm_original_update = hf_tqdm_class.update
|
||||
|
||||
# Create a wrapper that calls our tracking
|
||||
tracker = self # Reference to HFProgressTracker instance
|
||||
def patched_update(tqdm_self, n=1):
|
||||
result = tracker._hf_tqdm_original_update(tqdm_self, n)
|
||||
|
||||
# Track this progress
|
||||
with tracker._lock:
|
||||
desc = getattr(tqdm_self, 'desc', '') or ''
|
||||
current = getattr(tqdm_self, 'n', 0)
|
||||
total = getattr(tqdm_self, 'total', 0) or 0
|
||||
|
||||
# Skip non-byte progress bars
|
||||
if 'fetching' in desc.lower():
|
||||
return result
|
||||
|
||||
# Skip until we have a meaningful total (at least 1MB)
|
||||
# This avoids the "100% at 0MB" issue when small config
|
||||
# files are counted before the real model files
|
||||
MIN_TOTAL_BYTES = 1_000_000 # 1MB
|
||||
if total >= MIN_TOTAL_BYTES:
|
||||
tracker._total_downloaded = current
|
||||
tracker._total_size = total
|
||||
|
||||
if tracker.progress_callback:
|
||||
tracker.progress_callback(current, total, desc)
|
||||
|
||||
return result
|
||||
|
||||
hf_tqdm_class.update = patched_update
|
||||
patched_count += 1
|
||||
print(f"[HFProgressTracker] Monkey-patched huggingface_hub.utils.tqdm.tqdm.update")
|
||||
except (ImportError, AttributeError) as e:
|
||||
print(f"[HFProgressTracker] Could not monkey-patch hf_tqdm: {e}")
|
||||
|
||||
print(f"[HFProgressTracker] Patched {patched_count} tqdm references")
|
||||
|
||||
yield
|
||||
|
||||
except ImportError:
|
||||
@@ -178,15 +298,24 @@ class HFProgressTracker:
|
||||
tqdm_module.auto.tqdm = self._original_tqdm_auto
|
||||
|
||||
# Restore patched modules
|
||||
for module_name, original in self._patched_modules.items():
|
||||
for key, (module, attr_name, original) in self._patched_modules.items():
|
||||
try:
|
||||
module = sys.modules.get(module_name)
|
||||
if module and original:
|
||||
setattr(module, "tqdm", original)
|
||||
setattr(module, attr_name, original)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
self._patched_modules = {}
|
||||
|
||||
# Restore hf_tqdm's original update method
|
||||
if self._hf_tqdm_original_update:
|
||||
try:
|
||||
from huggingface_hub.utils import tqdm as hf_tqdm_module
|
||||
if hasattr(hf_tqdm_module, 'tqdm'):
|
||||
hf_tqdm_module.tqdm.update = self._hf_tqdm_original_update
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
self._hf_tqdm_original_update = None
|
||||
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
@@ -194,13 +323,17 @@ class HFProgressTracker:
|
||||
def create_hf_progress_callback(model_name: str, progress_manager):
|
||||
"""Create a progress callback for HuggingFace downloads."""
|
||||
def callback(downloaded: int, total: int, filename: str = ""):
|
||||
"""Progress callback."""
|
||||
if total > 0:
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=downloaded,
|
||||
total=total,
|
||||
filename=filename or "",
|
||||
status="downloading",
|
||||
)
|
||||
"""Progress callback.
|
||||
|
||||
Note: We send updates even when total=0 (unknown) to provide feedback
|
||||
during the "incomplete total" phase of huggingface_hub downloads.
|
||||
The frontend handles total=0 gracefully.
|
||||
"""
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=downloaded,
|
||||
total=total,
|
||||
filename=filename or "",
|
||||
status="downloading",
|
||||
)
|
||||
return callback
|
||||
|
||||
@@ -16,11 +16,17 @@ class ProgressManager:
|
||||
Thread-safe: can be called from background threads (e.g., via asyncio.to_thread).
|
||||
"""
|
||||
|
||||
# Throttle settings to prevent overwhelming SSE clients
|
||||
THROTTLE_INTERVAL_SECONDS = 0.5 # Minimum time between updates
|
||||
THROTTLE_PROGRESS_DELTA = 1.0 # Minimum progress change (%) to force update
|
||||
|
||||
def __init__(self):
|
||||
self._progress: Dict[str, Dict] = {}
|
||||
self._listeners: Dict[str, list] = {}
|
||||
self._lock = threading.Lock() # Thread-safe lock for progress dict
|
||||
self._main_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._last_notify_time: Dict[str, float] = {} # Last notification time per model
|
||||
self._last_notify_progress: Dict[str, float] = {} # Last notified progress per model
|
||||
|
||||
def _set_main_loop(self, loop: asyncio.AbstractEventLoop):
|
||||
"""Set the main event loop for thread-safe operations."""
|
||||
@@ -43,11 +49,17 @@ class ProgressManager:
|
||||
queue.put_nowait(progress_data.copy())
|
||||
except RuntimeError:
|
||||
# Not in async context (running in background thread)
|
||||
# Use call_soon_threadsafe to safely put on queue
|
||||
# Use asyncio.run_coroutine_threadsafe for better PyInstaller compatibility
|
||||
if self._main_loop and self._main_loop.is_running():
|
||||
self._main_loop.call_soon_threadsafe(
|
||||
lambda q=queue, d=progress_data.copy(): q.put_nowait(d) if not q.full() else None
|
||||
)
|
||||
async def put_data_async():
|
||||
try:
|
||||
queue.put_nowait(progress_data.copy())
|
||||
except asyncio.QueueFull:
|
||||
pass # Queue full, drop update
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(put_data_async(), self._main_loop)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to schedule progress update: {e}")
|
||||
else:
|
||||
logger.debug(f"No main loop available for {model_name}, skipping notification")
|
||||
except asyncio.QueueFull:
|
||||
@@ -65,8 +77,12 @@ class ProgressManager:
|
||||
):
|
||||
"""
|
||||
Update progress for a model download.
|
||||
|
||||
|
||||
Thread-safe: can be called from background threads.
|
||||
|
||||
Progress updates are throttled to prevent overwhelming SSE clients.
|
||||
Updates are sent at most every THROTTLE_INTERVAL_SECONDS, or when
|
||||
progress changes by at least THROTTLE_PROGRESS_DELTA percent.
|
||||
|
||||
Args:
|
||||
model_name: Name of the model (e.g., "qwen-tts-1.7B", "whisper-base")
|
||||
@@ -76,9 +92,17 @@ class ProgressManager:
|
||||
status: Status string (downloading, extracting, complete, error)
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
progress_pct = (current / total * 100) if total > 0 else 0
|
||||
# Calculate progress percentage, clamped to 0-100 range
|
||||
# This prevents crazy percentages from edge cases like:
|
||||
# - current > total temporarily during aggregation
|
||||
# - mixing file-count progress with byte-count progress
|
||||
if total > 0:
|
||||
progress_pct = min(100.0, max(0.0, (current / total * 100)))
|
||||
else:
|
||||
progress_pct = 0
|
||||
|
||||
progress_data = {
|
||||
"model_name": model_name,
|
||||
@@ -89,13 +113,36 @@ class ProgressManager:
|
||||
"status": status,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# Thread-safe update of progress dict
|
||||
|
||||
# Thread-safe update of progress dict (always update internal state)
|
||||
with self._lock:
|
||||
self._progress[model_name] = progress_data
|
||||
|
||||
# Check if we should notify listeners (throttling)
|
||||
current_time = time.time()
|
||||
last_time = self._last_notify_time.get(model_name, 0)
|
||||
last_progress = self._last_notify_progress.get(model_name, -100)
|
||||
|
||||
time_delta = current_time - last_time
|
||||
progress_delta = abs(progress_pct - last_progress)
|
||||
|
||||
# Always notify for complete/error status, or if throttle conditions are met
|
||||
should_notify = (
|
||||
status in ("complete", "error") or
|
||||
time_delta >= self.THROTTLE_INTERVAL_SECONDS or
|
||||
progress_delta >= self.THROTTLE_PROGRESS_DELTA
|
||||
)
|
||||
|
||||
if not should_notify:
|
||||
return # Skip this update (throttled)
|
||||
|
||||
# Update throttle tracking
|
||||
self._last_notify_time[model_name] = current_time
|
||||
self._last_notify_progress[model_name] = progress_pct
|
||||
|
||||
# Notify all listeners (thread-safe)
|
||||
listener_count = len(self._listeners.get(model_name, []))
|
||||
|
||||
if listener_count > 0:
|
||||
logger.debug(f"Notifying {listener_count} listeners for {model_name}: {progress_pct:.1f}% ({filename})")
|
||||
self._notify_listeners_threadsafe(model_name, progress_data)
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
from PyInstaller.utils.hooks import collect_data_files
|
||||
from PyInstaller.utils.hooks import collect_submodules
|
||||
from PyInstaller.utils.hooks import copy_metadata
|
||||
|
||||
datas = []
|
||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.asr']
|
||||
datas += collect_data_files('qwen_tts')
|
||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.providers', 'backend.providers.base', 'backend.providers.bundled', 'backend.providers.types', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'pkg_resources.extern', 'asyncio', 'asyncio.subprocess', 'concurrent.futures', 'concurrent.futures.thread', 'backend.backends', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
||||
datas += collect_data_files('mlx')
|
||||
datas += collect_data_files('mlx_audio')
|
||||
datas += copy_metadata('qwen-tts')
|
||||
hiddenimports += collect_submodules('qwen_tts')
|
||||
hiddenimports += collect_submodules('jaraco')
|
||||
hiddenimports += collect_submodules('mlx')
|
||||
hiddenimports += collect_submodules('mlx_audio')
|
||||
|
||||
@@ -13,12 +13,16 @@
|
||||
},
|
||||
"app": {
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.4",
|
||||
"@iconify-json/svg-spinners": "^1.2.4",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
@@ -26,6 +30,7 @@
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
@@ -45,7 +50,6 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"framer-motion": "^12.29.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"motion": "^12.29.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
@@ -68,7 +72,7 @@
|
||||
},
|
||||
"landing": {
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
@@ -93,10 +97,14 @@
|
||||
},
|
||||
"tauri": {
|
||||
"name": "@voicebox/tauri",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
@@ -112,7 +120,7 @@
|
||||
},
|
||||
"web": {
|
||||
"name": "@voicebox/web",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"react": "^18.3.0",
|
||||
@@ -121,6 +129,7 @@
|
||||
"zustand": "^4.5.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
@@ -129,6 +138,7 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
},
|
||||
@@ -267,12 +277,22 @@
|
||||
|
||||
"@hookform/resolvers": ["@hookform/[email protected]", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="],
|
||||
|
||||
"@hugeicons/core-free-icons": ["@hugeicons/[email protected]", "", {}, "sha512-UpS2lUQFi5sKyJSWwM6rO+BnPLvVz1gsyCpPHeZyVuZqi89YH8ksliza4cwaODqKOZyeXmG8juo1ty4QtQofkg=="],
|
||||
|
||||
"@hugeicons/react": ["@hugeicons/[email protected]", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-gsc3eZyd2fGqRUThW9+lfjxxsOkz6KNVmRXRgJjP32GL0OnnLJnl3hytKt47CBbiQj2xE2kCw+rnP3UQCThcKw=="],
|
||||
|
||||
"@humanwhocodes/config-array": ["@humanwhocodes/[email protected]", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/[email protected]", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/object-schema": ["@humanwhocodes/[email protected]", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
|
||||
|
||||
"@iconify-json/svg-spinners": ["@iconify-json/[email protected]", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-ayn0pogFPwJA1WFZpDnoq9/hjDxN+keeCMyThaX4d3gSJ3y0mdKUxIA/b1YXWGtY9wVtZmxwcvOIeEieG4+JNg=="],
|
||||
|
||||
"@iconify/react": ["@iconify/[email protected]", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg=="],
|
||||
|
||||
"@iconify/types": ["@iconify/[email protected]", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@img/colour": ["@img/[email protected]", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
@@ -403,6 +423,8 @@
|
||||
|
||||
"@radix-ui/react-progress": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="],
|
||||
|
||||
"@radix-ui/react-radio-group": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
"@radix-ui/react-scroll-area": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||
@@ -877,7 +899,7 @@
|
||||
|
||||
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.454.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ=="],
|
||||
"lucide-react": ["lucide-react@0.316.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-dTmYX1H4IXsRfVcj/KUxworV6814ApTl7iXaS21AimK2RUEl4j4AfOmqD3VR8phe5V91m4vEJ8tCK4uT1jE5nA=="],
|
||||
|
||||
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
@@ -1133,8 +1155,6 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"@voicebox/landing/lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-dTmYX1H4IXsRfVcj/KUxworV6814ApTl7iXaS21AimK2RUEl4j4AfOmqD3VR8phe5V91m4vEJ8tCK4uT1jE5nA=="],
|
||||
|
||||
"@voicebox/landing/tailwind-merge": ["[email protected]", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||
|
||||
"@voicebox/landing/tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# User data directory
|
||||
# This directory contains:
|
||||
# - profiles/ - Voice profile audio files
|
||||
# - generations/ - Generated audio files
|
||||
# - projects/ - Audio studio project files
|
||||
# - voicebox.db - SQLite database
|
||||
# - cache/ - Voice prompt cache files
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
# Voice prompt cache files
|
||||
@@ -0,0 +1,26 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:latest
|
||||
container_name: voicebox
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- voicebox-data:/app/data
|
||||
- huggingface-cache:/root/.cache/huggingface
|
||||
environment:
|
||||
- LOG_LEVEL=info
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
voicebox-data:
|
||||
driver: local
|
||||
huggingface-cache:
|
||||
driver: local
|
||||
@@ -0,0 +1,34 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
container_name: voicebox
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- voicebox-data:/app/data
|
||||
- huggingface-cache:/root/.cache/huggingface
|
||||
environment:
|
||||
- GPU_MEMORY_FRACTION=0.8
|
||||
- LOG_LEVEL=info
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
voicebox-data:
|
||||
driver: local
|
||||
huggingface-cache:
|
||||
driver: local
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"icon": "rocket",
|
||||
"pages": ["overview/introduction", "overview/installation", "overview/quick-start"]
|
||||
"pages": ["overview/introduction", "overview/installation", "overview/docker", "overview/quick-start"]
|
||||
},
|
||||
{
|
||||
"group": "Features",
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
---
|
||||
title: "Docker Deployment"
|
||||
description: "Run Voicebox in Docker with the web UI for server deployments"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Voicebox is available as Docker images that include both the backend API and web UI. Run the full Voicebox experience in a container with a single command.
|
||||
|
||||
**What's included:**
|
||||
- FastAPI backend with all TTS/Whisper capabilities
|
||||
- Complete web UI (same React app as the desktop version)
|
||||
- Provider download system (downloads PyTorch on first use)
|
||||
- Multi-architecture support (amd64, arm64)
|
||||
|
||||
## Quick Start
|
||||
|
||||
<Tabs>
|
||||
<Tab title="NVIDIA GPU">
|
||||
```bash
|
||||
docker run --gpus all -p 8000:8000 \
|
||||
-v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
```
|
||||
|
||||
Then open http://localhost:8000 to access the web UI.
|
||||
</Tab>
|
||||
|
||||
<Tab title="CPU Only">
|
||||
```bash
|
||||
docker run -p 8000:8000 \
|
||||
-v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest
|
||||
```
|
||||
|
||||
Then open http://localhost:8000 to access the web UI.
|
||||
</Tab>
|
||||
|
||||
<Tab title="Docker Compose">
|
||||
Clone the repo or download `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
# CUDA variant (default)
|
||||
docker compose up -d
|
||||
|
||||
# CPU-only variant
|
||||
docker compose -f docker-compose-cpu.yml up -d
|
||||
```
|
||||
|
||||
Then open http://localhost:8000 to access the web UI.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Note>
|
||||
On first launch, you'll be prompted to download a TTS provider (PyTorch CPU ~300MB or PyTorch CUDA ~2.4GB). This happens once and is cached in the `huggingface-cache` volume.
|
||||
</Note>
|
||||
|
||||
## Available Images
|
||||
|
||||
Images are automatically built and published to GitHub Container Registry on each release.
|
||||
|
||||
| Image | Description | Platforms |
|
||||
|-------|-------------|-----------|
|
||||
| `ghcr.io/jamiepine/voicebox:latest` | Latest CPU-only release | linux/amd64, linux/arm64 |
|
||||
| `ghcr.io/jamiepine/voicebox:0.1.13` | Specific version (CPU) | linux/amd64, linux/arm64 |
|
||||
| `ghcr.io/jamiepine/voicebox:latest-cuda` | Latest with NVIDIA GPU support | linux/amd64 |
|
||||
| `ghcr.io/jamiepine/voicebox:0.1.13-cuda` | Specific version (CUDA) | linux/amd64 |
|
||||
|
||||
<Tip>
|
||||
Pin to a specific version in production to avoid unexpected updates:
|
||||
```yaml
|
||||
image: ghcr.io/jamiepine/voicebox:0.1.13-cuda
|
||||
```
|
||||
</Tip>
|
||||
|
||||
## Docker Compose Examples
|
||||
|
||||
### GPU Deployment (Recommended)
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
container_name: voicebox
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- voicebox-data:/app/data
|
||||
- huggingface-cache:/root/.cache/huggingface
|
||||
environment:
|
||||
- GPU_MEMORY_FRACTION=0.8
|
||||
- LOG_LEVEL=info
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
volumes:
|
||||
voicebox-data:
|
||||
huggingface-cache:
|
||||
```
|
||||
|
||||
### CPU Deployment
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:latest
|
||||
container_name: voicebox
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- voicebox-data:/app/data
|
||||
- huggingface-cache:/root/.cache/huggingface
|
||||
environment:
|
||||
- LOG_LEVEL=info
|
||||
|
||||
volumes:
|
||||
voicebox-data:
|
||||
huggingface-cache:
|
||||
```
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="voicebox-data" icon="database">
|
||||
Stores voice profiles, generated audio, and database
|
||||
</Card>
|
||||
<Card title="huggingface-cache" icon="download">
|
||||
Caches downloaded TTS/Whisper models (saves re-downloading)
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Warning>
|
||||
Always mount `/app/data` to preserve your voice profiles and generations across container restarts.
|
||||
</Warning>
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Configure Voicebox behavior with environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `GPU_MEMORY_FRACTION` | `0.9` | Fraction of GPU memory to use (0.0-1.0) |
|
||||
| `LOG_LEVEL` | `info` | Logging level: `debug`, `info`, `warning`, `error` |
|
||||
| `DATA_DIR` | `/app/data` | Directory for profiles and generations |
|
||||
|
||||
Example:
|
||||
```bash
|
||||
docker run -e GPU_MEMORY_FRACTION=0.8 \
|
||||
-e LOG_LEVEL=debug \
|
||||
-p 8000:8000 \
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
```
|
||||
|
||||
## Cloud Deployment
|
||||
|
||||
### AWS EC2
|
||||
|
||||
<Steps>
|
||||
<Step title="Launch GPU Instance">
|
||||
Use g4dn.xlarge or p3.2xlarge with NVIDIA GPU
|
||||
</Step>
|
||||
|
||||
<Step title="Install Docker & NVIDIA Container Toolkit">
|
||||
```bash
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
|
||||
# Install NVIDIA Container Toolkit
|
||||
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
|
||||
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
||||
curl -s -L https://nvidia.github.io/libnvidia-container/$distribution/libnvidia-container.list | \
|
||||
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
|
||||
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nvidia-container-toolkit
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Deploy">
|
||||
```bash
|
||||
docker run -d --gpus all -p 8000:8000 \
|
||||
-v voicebox-data:/app/data \
|
||||
--restart unless-stopped \
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### DigitalOcean
|
||||
|
||||
<Steps>
|
||||
<Step title="Create GPU Droplet">
|
||||
```bash
|
||||
doctl compute droplet create voicebox \
|
||||
--size gpu-h100x1-80gb \
|
||||
--image ubuntu-22-04-x64 \
|
||||
--region nyc3
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="SSH and Deploy">
|
||||
```bash
|
||||
ssh root@<droplet-ip>
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
docker run -d --gpus all -p 80:8000 \
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Fly.io
|
||||
|
||||
Create `fly.toml`:
|
||||
|
||||
```toml
|
||||
app = "voicebox"
|
||||
|
||||
[build]
|
||||
image = "ghcr.io/jamiepine/voicebox:latest"
|
||||
|
||||
[[services]]
|
||||
http_checks = []
|
||||
internal_port = 8000
|
||||
protocol = "tcp"
|
||||
|
||||
[[services.ports]]
|
||||
port = 80
|
||||
handlers = ["http"]
|
||||
|
||||
[[services.ports]]
|
||||
port = 443
|
||||
handlers = ["tls", "http"]
|
||||
|
||||
[mounts]
|
||||
source = "voicebox_data"
|
||||
destination = "/app/data"
|
||||
```
|
||||
|
||||
Deploy:
|
||||
```bash
|
||||
fly launch
|
||||
fly deploy
|
||||
```
|
||||
|
||||
## Updates
|
||||
|
||||
Docker images are automatically built and published on each GitHub release.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Latest Tag">
|
||||
Always get the newest version:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/jamiepine/voicebox:latest
|
||||
docker compose up -d
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="Pinned Version">
|
||||
Update to a specific version:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:0.1.13-cuda
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="Automatic Updates">
|
||||
Use Watchtower for automatic updates:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
# ... other config ...
|
||||
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
command: --interval 3600 # Check hourly
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## GPU Requirements
|
||||
|
||||
### NVIDIA GPU
|
||||
|
||||
Requires:
|
||||
- **Docker version:** 19.03+
|
||||
- **NVIDIA Driver:** 450.80.02+
|
||||
- **NVIDIA Container Toolkit:** Installed and configured
|
||||
|
||||
Verify GPU access:
|
||||
```bash
|
||||
docker run --rm --gpus all nvidia/cuda:12.1.1-base-ubuntu22.04 nvidia-smi
|
||||
```
|
||||
|
||||
If this works, Voicebox will detect and use your GPU automatically.
|
||||
|
||||
### AMD GPU (ROCm)
|
||||
|
||||
AMD GPU support via ROCm is not currently available in pre-built images. If you need ROCm support, build a custom image using the ROCm base.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### GPU Not Detected
|
||||
|
||||
<Accordion title="Check NVIDIA Docker">
|
||||
```bash
|
||||
# Verify NVIDIA Container Toolkit is installed
|
||||
docker run --rm --gpus all nvidia/cuda:12.1.1-base-ubuntu22.04 nvidia-smi
|
||||
```
|
||||
|
||||
If this fails, reinstall NVIDIA Container Toolkit.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Insufficient GPU Memory">
|
||||
Reduce GPU memory usage:
|
||||
|
||||
```bash
|
||||
docker run -e GPU_MEMORY_FRACTION=0.5 \
|
||||
--gpus all -p 8000:8000 \
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
```
|
||||
|
||||
Or use CPU-only mode:
|
||||
```bash
|
||||
docker run -p 8000:8000 \
|
||||
ghcr.io/jamiepine/voicebox:latest
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Port Already in Use">
|
||||
Change the host port:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8000 ghcr.io/jamiepine/voicebox:latest
|
||||
```
|
||||
|
||||
Then open http://localhost:8080
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Permission Errors">
|
||||
Run with specific user:
|
||||
|
||||
```bash
|
||||
docker run --user $(id -u):$(id -g) \
|
||||
-v $(pwd)/data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
## Building From Source
|
||||
|
||||
If you need to customize the Docker image:
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/jamiepine/voicebox.git
|
||||
cd voicebox
|
||||
|
||||
# Build web UI
|
||||
bun install
|
||||
cd web && bun run build && cd ..
|
||||
|
||||
# Build Docker image
|
||||
docker build -t voicebox:custom .
|
||||
|
||||
# Or CUDA variant
|
||||
docker build -f Dockerfile.cuda -t voicebox:custom-cuda .
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="API Reference" icon="code" href="/api/overview">
|
||||
Integrate Voicebox into your applications
|
||||
</Card>
|
||||
<Card title="Remote Mode" icon="server" href="/overview/remote-mode">
|
||||
Connect desktop app to Docker backend
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -5,15 +5,21 @@ description: "Download and install Voicebox on macOS, Windows, or Linux"
|
||||
|
||||
## Download
|
||||
|
||||
Voicebox is available for macOS and Windows, with Linux builds coming soon.
|
||||
Voicebox is available for macOS, Windows, and Linux.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<CardGroup cols={4}>
|
||||
<Card title="macOS" icon="apple">
|
||||
Download for Apple Silicon or Intel Macs
|
||||
</Card>
|
||||
<Card title="Windows" icon="windows">
|
||||
Download MSI installer or Setup executable
|
||||
</Card>
|
||||
<Card title="Linux" icon="linux">
|
||||
Download AppImage or Deb package
|
||||
</Card>
|
||||
<Card title="Docker" icon="docker" href="/overview/docker">
|
||||
Run with web UI in a container
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
### macOS
|
||||
@@ -60,8 +66,33 @@ Voicebox is available for macOS and Windows, with Linux builds coming soon.
|
||||
|
||||
### Linux
|
||||
|
||||
<Tabs>
|
||||
<Tab title="AppImage">
|
||||
Download: [voicebox_x86_64.AppImage](https://github.com/jamiepine/voicebox/releases/latest)
|
||||
|
||||
```bash
|
||||
# Make executable
|
||||
chmod +x voicebox_x86_64.AppImage
|
||||
|
||||
# Run
|
||||
./voicebox_x86_64.AppImage
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Debian/Ubuntu">
|
||||
Download: [voicebox_amd64.deb](https://github.com/jamiepine/voicebox/releases/latest)
|
||||
|
||||
```bash
|
||||
# Install
|
||||
sudo dpkg -i voicebox_amd64.deb
|
||||
|
||||
# Run
|
||||
voicebox
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Note>
|
||||
Linux builds are coming soon. Currently blocked by GitHub runner disk space limitations.
|
||||
For headless server deployments, use [Docker](/overview/docker) instead of the desktop app.
|
||||
</Note>
|
||||
|
||||
## First Launch
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "Welcome to Voicebox - the open-source voice synthesis studio"
|
||||
|
||||
## What is Voicebox?
|
||||
|
||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as the **Ollama for voice** — download models, clone voices, and generate speech entirely on your machine.
|
||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as a **local, free and open-source alternative to ElevenLabs** — download models, clone voices, and generate speech entirely on your machine.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/app-screenshot-1.webp" alt="Voicebox App Screenshot" />
|
||||
|
||||
+72
-193
@@ -1,24 +1,31 @@
|
||||
# Docker Deployment Guide
|
||||
|
||||
**Status:** In Development for v0.2.0
|
||||
**Requested By:** Reddit community ([thread](https://reddit.com/r/LocalLLaMA/...))
|
||||
**Status:** Implemented
|
||||
**Images:** `ghcr.io/jamiepine/voicebox`
|
||||
|
||||
## Overview
|
||||
|
||||
Docker support makes Voicebox easier to deploy, especially for:
|
||||
Voicebox is available as Docker images with the full web UI included. Images are automatically built and published to GitHub Container Registry on each release.
|
||||
|
||||
- **Consistent Environments**: Same setup across dev/staging/prod
|
||||
- **GPU Passthrough**: Easy NVIDIA/AMD GPU access
|
||||
**What's included:**
|
||||
- FastAPI backend with all TTS/Whisper capabilities
|
||||
- Complete web UI (same React app as the Tauri desktop version)
|
||||
- Provider download system (downloads TTS providers on first use, just like desktop)
|
||||
- Multi-architecture support (amd64, arm64 for CPU variant)
|
||||
|
||||
Docker support is ideal for:
|
||||
- **Server Deployments**: Run on headless Linux servers
|
||||
- **Multi-User Setups**: Isolate instances per user/team
|
||||
- **GPU Passthrough**: Easy NVIDIA GPU access
|
||||
- **Consistent Environments**: Same setup across dev/staging/prod
|
||||
- **Cloud Platforms**: Deploy to AWS, GCP, Azure, DigitalOcean
|
||||
- **Multi-User Setups**: Isolate instances per user/team
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Pre-Built Images (Recommended)
|
||||
|
||||
```bash
|
||||
# CPU-only version
|
||||
# CPU-only version (supports amd64 and arm64)
|
||||
docker run -p 8000:8000 -v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest
|
||||
|
||||
@@ -26,184 +33,80 @@ docker run -p 8000:8000 -v voicebox-data:/app/data \
|
||||
docker run --gpus all -p 8000:8000 -v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
|
||||
# AMD GPU version (experimental)
|
||||
docker run --device=/dev/kfd --device=/dev/dri -p 8000:8000 \
|
||||
-v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:latest-rocm
|
||||
# Specific version (pinned for stability)
|
||||
docker run -p 8000:8000 -v voicebox-data:/app/data \
|
||||
ghcr.io/jamiepine/voicebox:0.1.13
|
||||
```
|
||||
|
||||
Then open: `http://localhost:8000`
|
||||
|
||||
The web UI will load automatically. On first use, you'll be prompted to download a TTS provider (PyTorch CPU ~300MB or PyTorch CUDA ~2.4GB).
|
||||
|
||||
### Using Docker Compose (Easiest)
|
||||
|
||||
Create `docker-compose.yml`:
|
||||
Use the provided `docker-compose.yml` (CUDA) or `docker-compose.cpu.yml` in the repository root:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
```bash
|
||||
# CUDA (default)
|
||||
docker compose up -d
|
||||
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- voicebox-data:/app/data
|
||||
- huggingface-cache:/root/.cache/huggingface
|
||||
environment:
|
||||
- GPU_MEMORY_FRACTION=0.8 # Use 80% of GPU memory
|
||||
- TTS_MODE=local
|
||||
- WHISPER_MODE=local
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
volumes:
|
||||
voicebox-data:
|
||||
huggingface-cache:
|
||||
# Or CPU-only
|
||||
docker compose -f docker-compose.cpu.yml up -d
|
||||
```
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker compose up -d
|
||||
To pin to a specific version, edit the compose file:
|
||||
```yaml
|
||||
services:
|
||||
voicebox:
|
||||
image: ghcr.io/jamiepine/voicebox:0.1.13-cuda # Pinned version
|
||||
```
|
||||
|
||||
## Building From Source
|
||||
|
||||
### Basic Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
build-essential \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy application
|
||||
COPY backend/ /app/backend/
|
||||
COPY requirements.txt /app/
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run server
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
See `Dockerfile` and `Dockerfile.cuda` in the repository root.
|
||||
|
||||
Build and run:
|
||||
```bash
|
||||
# Build web UI first
|
||||
bun install
|
||||
cd web && bun run build && cd ..
|
||||
|
||||
# Build CPU image
|
||||
docker build -t voicebox .
|
||||
docker run -p 8000:8000 -v $(pwd)/data:/app/data voicebox
|
||||
docker run -p 8000:8000 -v voicebox-data:/app/data voicebox
|
||||
|
||||
# Or build CUDA image
|
||||
docker build -f Dockerfile.cuda -t voicebox:cuda .
|
||||
docker run --gpus all -p 8000:8000 -v voicebox-data:/app/data voicebox:cuda
|
||||
```
|
||||
|
||||
### Multi-Stage Build (Optimized)
|
||||
### Architecture
|
||||
|
||||
Smaller image size by separating build and runtime:
|
||||
The Docker images include:
|
||||
- **Backend**: FastAPI server with TTS/Whisper endpoints
|
||||
- **Web UI**: Pre-built React app served as static files from the backend
|
||||
- **Provider System**: Downloads PyTorch CPU/CUDA providers on first use (same UX as desktop app)
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile.optimized
|
||||
# Stage 1: Build dependencies
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git build-essential && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir --target=/build/packages \
|
||||
-r requirements.txt
|
||||
|
||||
RUN pip install --no-cache-dir --target=/build/packages \
|
||||
git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed packages from builder
|
||||
COPY --from=builder /build/packages /usr/local/lib/python3.11/site-packages/
|
||||
|
||||
# Copy application code
|
||||
COPY backend/ /app/backend/
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
Build:
|
||||
```bash
|
||||
docker build -f Dockerfile.optimized -t voicebox:slim .
|
||||
```
|
||||
Images are automatically built on release and tagged with both version number and `latest`.
|
||||
|
||||
## GPU Support
|
||||
|
||||
### NVIDIA GPUs (CUDA)
|
||||
|
||||
**Dockerfile:**
|
||||
```dockerfile
|
||||
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
|
||||
|
||||
# Install Python
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3.11 python3-pip git ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install PyTorch with CUDA support
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||
|
||||
# Install other dependencies
|
||||
RUN pip3 install -r requirements.txt
|
||||
RUN pip3 install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
|
||||
COPY backend/ /app/backend/
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
The CUDA image includes PyTorch with CUDA 12.1 support:
|
||||
|
||||
**Run with GPU:**
|
||||
```bash
|
||||
docker run --gpus all -p 8000:8000 \
|
||||
-v voicebox-data:/app/data \
|
||||
voicebox:cuda
|
||||
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
```
|
||||
|
||||
**Docker Compose with GPU:**
|
||||
```yaml
|
||||
services:
|
||||
voicebox:
|
||||
image: voicebox:cuda
|
||||
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
@@ -213,47 +116,9 @@ services:
|
||||
capabilities: [gpu]
|
||||
```
|
||||
|
||||
### AMD GPUs (ROCm) - Experimental
|
||||
### AMD GPUs (ROCm)
|
||||
|
||||
**Dockerfile:**
|
||||
```dockerfile
|
||||
FROM rocm/dev-ubuntu-22.04:6.0
|
||||
|
||||
# Install Python
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3.11 python3-pip git ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install PyTorch with ROCm support
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.0
|
||||
|
||||
# Install other dependencies
|
||||
RUN pip3 install -r requirements.txt
|
||||
RUN pip3 install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
|
||||
# Set ROCm environment variables
|
||||
ENV HSA_OVERRIDE_GFX_VERSION=10.3.0
|
||||
ENV ROCM_PATH=/opt/rocm
|
||||
|
||||
COPY backend/ /app/backend/
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
**Run with AMD GPU:**
|
||||
```bash
|
||||
docker run --device=/dev/kfd --device=/dev/dri \
|
||||
--group-add video --ipc=host --cap-add=SYS_PTRACE \
|
||||
--security-opt seccomp=unconfined \
|
||||
-p 8000:8000 -v voicebox-data:/app/data \
|
||||
voicebox:rocm
|
||||
```
|
||||
|
||||
**Note:** ROCm support varies by GPU model. Works best on Linux. See [AMD ROCm docs](https://rocm.docs.amd.com) for compatibility.
|
||||
ROCm support is not currently available in pre-built images. If you need ROCm, build a custom image using the ROCm base and PyTorch ROCm builds.
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
@@ -734,13 +599,27 @@ docker logs -f voicebox
|
||||
docker compose logs -f voicebox
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
## Updates
|
||||
|
||||
- [ ] Publish official images to GitHub Container Registry
|
||||
- [ ] Add Kubernetes Helm charts
|
||||
- [ ] Create Docker Desktop extension
|
||||
- [ ] Add automated vulnerability scanning
|
||||
- [ ] Support ARM64 builds for Raspberry Pi / Apple Silicon
|
||||
Docker images are automatically built and published on each GitHub release. To update:
|
||||
|
||||
```bash
|
||||
# Pull latest
|
||||
docker pull ghcr.io/jamiepine/voicebox:latest
|
||||
docker compose up -d
|
||||
|
||||
# Or pin to a specific version
|
||||
docker pull ghcr.io/jamiepine/voicebox:0.1.13
|
||||
```
|
||||
|
||||
For automatic updates, use [Watchtower](https://containrrr.dev/watchtower/).
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Kubernetes Helm charts
|
||||
- Docker Desktop extension
|
||||
- Automated vulnerability scanning
|
||||
- ROCm image variant
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user