mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-27 22:25:15 -07:00
Compare commits
136
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ebf6222e | ||
|
|
509b0e71cc | ||
|
|
81f8be1a94 | ||
|
|
655a60ca81 | ||
|
|
52285362ce | ||
|
|
3ea587797f | ||
|
|
325714bb83 | ||
|
|
9aa7080c51 | ||
|
|
97292ecef7 | ||
|
|
837f8525d8 | ||
|
|
70ca7f66cb | ||
|
|
c12b5d6f0a | ||
|
|
139fa38e3f | ||
|
|
0e9f5db40f | ||
|
|
2f535a772f | ||
|
|
b420637957 | ||
|
|
bfd7b815a5 | ||
|
|
cac80f6af0 | ||
|
|
47ce4cafdf | ||
|
|
bfe912e41a | ||
|
|
5ccf79a8f7 | ||
|
|
1d32170c2e | ||
|
|
ca74c155e2 | ||
|
|
1f770a157d | ||
|
|
d64e24d422 | ||
|
|
77d86ba835 | ||
|
|
986a748420 | ||
|
|
50e01d17f8 | ||
|
|
084c51b983 | ||
|
|
efbbbc7ec1 | ||
|
|
8e7f0cb9ad | ||
|
|
3357a06cba | ||
|
|
f58c7c1cf3 | ||
|
|
ea41213123 | ||
|
|
b5801891b8 | ||
|
|
8f77c041f5 | ||
|
|
5a3f3ba030 | ||
|
|
3c25ee6e2c | ||
|
|
b92b0dd508 | ||
|
|
670900bf5a | ||
|
|
219cfb1605 | ||
|
|
bf728a780c | ||
|
|
3e6513c0fb | ||
|
|
c54ee14173 | ||
|
|
cc07d4d3c9 | ||
|
|
9beb9d7fec | ||
|
|
76bb207b2b | ||
|
|
3576521d62 | ||
|
|
2df4ece388 | ||
|
|
cbb4979ed6 | ||
|
|
753158c1c9 | ||
|
|
573f82a7e6 | ||
|
|
1e5afc2bef | ||
|
|
163528bf69 | ||
|
|
e1ad7a6e73 | ||
|
|
411e91bb19 | ||
|
|
05cf163744 | ||
|
|
d46eb5bcc6 | ||
|
|
6359dee406 | ||
|
|
a69c216794 | ||
|
|
2867421550 | ||
|
|
758577fd4b | ||
|
|
3d2506767d | ||
|
|
cdef2163c1 | ||
|
|
9955e1dcb7 | ||
|
|
19a28bf6c5 | ||
|
|
a8ecf3f31d | ||
|
|
d744e634a8 | ||
|
|
a362d7de2a | ||
|
|
3f10a70d4c | ||
|
|
d0dfe78701 | ||
|
|
172addd918 | ||
|
|
ada309cfb9 | ||
|
|
30ee07c2e3 | ||
|
|
d21c63b52c | ||
|
|
5ad67d7ecb | ||
|
|
6cc96c2614 | ||
|
|
38bf96ff20 | ||
|
|
0b14cb1b2c | ||
|
|
4d24e69012 | ||
|
|
90436e428d | ||
|
|
e4bb288904 | ||
|
|
6f8bc7f23b | ||
|
|
baca111d50 | ||
|
|
cc298fe6d8 | ||
|
|
46b8f6b882 | ||
|
|
edfc6e99fe | ||
|
|
d00e28ffda | ||
|
|
162cf4fb84 | ||
|
|
68558243d9 | ||
|
|
8d5ad926f9 | ||
|
|
334f037dce | ||
|
|
f6522eea80 | ||
|
|
7615a08f81 | ||
|
|
28a4fd4824 | ||
|
|
31ea3c68a5 | ||
|
|
54d72ddfd0 | ||
|
|
d4794f78e1 | ||
|
|
aa7c9a9a8d | ||
|
|
ca6ed0998a | ||
|
|
829d4d6d5b | ||
|
|
80c87c8e2c | ||
|
|
427d811954 | ||
|
|
0be7975db5 | ||
|
|
40e4af828a | ||
|
|
0e57826ea5 | ||
|
|
eb2cd861b1 | ||
|
|
701cc647a7 | ||
|
|
be6ccaf044 | ||
|
|
1040625a88 | ||
|
|
6f4503b521 | ||
|
|
f5b6edc2e7 | ||
|
|
8197f0724c | ||
|
|
d40f7d2676 | ||
|
|
99fbcca7f4 | ||
|
|
04f9880c9a | ||
|
|
b9c858295d | ||
|
|
610f64c762 | ||
|
|
220333b3bb | ||
|
|
e194e95512 | ||
|
|
e796412c2c | ||
|
|
cb541521d2 | ||
|
|
2bc243f93e | ||
|
|
0209008d73 | ||
|
|
9bde534860 | ||
|
|
97eb570b28 | ||
|
|
7d0557a099 | ||
|
|
60a03c56a9 | ||
|
|
d3393fb940 | ||
|
|
07c0aba883 | ||
|
|
77418a52ae | ||
|
|
46f6806e14 | ||
|
|
20851ccc2b | ||
|
|
0b17073345 | ||
|
|
17106b1e40 | ||
|
|
7fcca09f24 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 0.1.11
|
current_version = 0.1.13
|
||||||
commit = True
|
commit = True
|
||||||
tag = True
|
tag = True
|
||||||
tag_name = v{new_version}
|
tag_name = v{new_version}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Version control
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Desktop-only (not needed in web container)
|
||||||
|
tauri/
|
||||||
|
landing/
|
||||||
|
docs/
|
||||||
|
mlx-test/
|
||||||
|
scripts/
|
||||||
|
|
||||||
|
# Dependencies & build artifacts (rebuilt in Docker)
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Data (will be bind-mounted)
|
||||||
|
data/
|
||||||
|
backend/data/
|
||||||
|
|
||||||
|
# IDE & OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Config files not needed in container
|
||||||
|
biome.json
|
||||||
|
.biomeignore
|
||||||
|
.bumpversion.cfg
|
||||||
|
.npmrc
|
||||||
|
Makefile
|
||||||
|
CHANGELOG.md
|
||||||
|
CONTRIBUTING.md
|
||||||
|
SECURITY.md
|
||||||
|
LICENSE
|
||||||
|
README.md
|
||||||
|
backend/README.md
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
name: Build CUDA Backend
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-cuda-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pyinstaller
|
||||||
|
pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Install PyTorch with CUDA 12.1
|
||||||
|
run: |
|
||||||
|
pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall --no-deps
|
||||||
|
pip install torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||||
|
|
||||||
|
- name: Verify CUDA support in torch
|
||||||
|
run: |
|
||||||
|
python -c "import torch; print(f'CUDA available in build: {torch.cuda.is_available()}'); print(f'CUDA version: {torch.version.cuda}')"
|
||||||
|
|
||||||
|
- name: Build CUDA server binary
|
||||||
|
shell: bash
|
||||||
|
working-directory: backend
|
||||||
|
run: python build_binary.py --cuda
|
||||||
|
|
||||||
|
- name: Split binary for GitHub Releases
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python scripts/split_binary.py \
|
||||||
|
backend/dist/voicebox-server-cuda.exe \
|
||||||
|
--output release-assets/
|
||||||
|
|
||||||
|
- name: Upload split parts to GitHub Release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
release-assets/voicebox-server-cuda.part*.exe
|
||||||
|
release-assets/voicebox-server-cuda.sha256
|
||||||
|
release-assets/voicebox-server-cuda.manifest
|
||||||
|
draft: true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Upload binary as workflow artifact (for testing)
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: voicebox-server-cuda-windows
|
||||||
|
path: backend/dist/voicebox-server-cuda.exe
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# Linux CUDA build can be added later with:
|
||||||
|
# build-cuda-linux:
|
||||||
|
# runs-on: ubuntu-22.04
|
||||||
|
# ...
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: Build Windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pyinstaller
|
||||||
|
pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Build Python server
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
python build_binary.py
|
||||||
|
|
||||||
|
PLATFORM=$(rustc --print host-tuple)
|
||||||
|
mkdir -p ../tauri/src-tauri/binaries
|
||||||
|
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
|
||||||
|
echo "Built voicebox-server-${PLATFORM}.exe"
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: "./tauri/src-tauri -> target"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
projectPath: tauri
|
||||||
|
tagName: v__VERSION__
|
||||||
|
releaseName: "voicebox v__VERSION__ (test build)"
|
||||||
|
releaseBody: "Test build for audio export fix"
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: true
|
||||||
|
args: ""
|
||||||
|
includeUpdaterJson: false
|
||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -14,22 +14,22 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- platform: 'macos-latest'
|
- platform: "macos-latest"
|
||||||
args: '--target aarch64-apple-darwin'
|
args: "--target aarch64-apple-darwin"
|
||||||
python-version: '3.12'
|
python-version: "3.12"
|
||||||
backend: 'mlx'
|
backend: "mlx"
|
||||||
- platform: 'macos-15-intel'
|
- platform: "macos-15-intel"
|
||||||
args: '--target x86_64-apple-darwin'
|
args: "--target x86_64-apple-darwin"
|
||||||
python-version: '3.12'
|
python-version: "3.12"
|
||||||
backend: 'pytorch'
|
backend: "pytorch"
|
||||||
# - platform: 'ubuntu-22.04'
|
- platform: "ubuntu-22.04"
|
||||||
# args: ''
|
args: ""
|
||||||
# python-version: '3.12'
|
python-version: "3.12"
|
||||||
# backend: 'pytorch'
|
backend: "pytorch"
|
||||||
- platform: 'windows-latest'
|
- platform: "windows-latest"
|
||||||
args: ''
|
args: ""
|
||||||
python-version: '3.12'
|
python-version: "3.12"
|
||||||
backend: 'pytorch'
|
backend: "pytorch"
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
cache: 'pip'
|
cache: "pip"
|
||||||
|
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -66,6 +66,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pip install -r backend/requirements-mlx.txt
|
pip install -r backend/requirements-mlx.txt
|
||||||
|
|
||||||
|
# - name: Install PyTorch with CUDA (Windows only)
|
||||||
|
# if: matrix.platform == 'windows-latest'
|
||||||
|
# run: |
|
||||||
|
# pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall --no-deps
|
||||||
|
# pip install torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||||
|
|
||||||
- name: Build Python server (Linux/macOS)
|
- name: Build Python server (Linux/macOS)
|
||||||
if: matrix.platform != 'windows-latest'
|
if: matrix.platform != 'windows-latest'
|
||||||
run: |
|
run: |
|
||||||
@@ -100,7 +106,7 @@ jobs:
|
|||||||
- name: Rust cache
|
- name: Rust cache
|
||||||
uses: swatinem/rust-cache@v2
|
uses: swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
workspaces: './tauri/src-tauri -> target'
|
workspaces: "./tauri/src-tauri -> target"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
@@ -136,7 +142,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
projectPath: tauri
|
projectPath: tauri
|
||||||
tagName: v__VERSION__
|
tagName: v__VERSION__
|
||||||
releaseName: 'voicebox v__VERSION__'
|
releaseName: "voicebox v__VERSION__"
|
||||||
releaseBody: |
|
releaseBody: |
|
||||||
## What's Changed
|
## What's Changed
|
||||||
See the assets below to download and install this version.
|
See the assets below to download and install this version.
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ All notable changes to Voicebox will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Profile Name Validation** - Added proper validation to prevent duplicate profile names ([#134](https://github.com/jamiepine/voicebox/issues/134))
|
||||||
|
- Users now receive clear error messages when attempting to create or update profiles with duplicate names
|
||||||
|
- Improved error handling in create and update profile API endpoints
|
||||||
|
- Added comprehensive test suite for duplicate name validation
|
||||||
|
|
||||||
## [0.1.0] - 2026-01-25
|
## [0.1.0] - 2026-01-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -53,6 +61,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Audio export failing when Tauri save dialog returns object instead of string path
|
||||||
|
- OpenAPI client generator script now documents the local backend port and avoids an unused loop variable warning
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
|
- **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
|
||||||
- Includes Python version detection and compatibility warnings
|
- Includes Python version detection and compatibility warnings
|
||||||
|
|||||||
+22
-2
@@ -27,12 +27,32 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
|||||||
```bash
|
```bash
|
||||||
rustc --version # Check if installed
|
rustc --version # Check if installed
|
||||||
```
|
```
|
||||||
|
- **[Tauri Prerequisites](https://v2.tauri.app/start/prerequisites)** - Tauri-specific system dependencies (varies by OS).
|
||||||
|
|
||||||
- **Git** - Version control
|
- **Git** - Version control
|
||||||
|
|
||||||
### Development Setup
|
### Development Setup
|
||||||
|
|
||||||
**Using the Makefile (recommended for macOS/Linux):** Run `make setup` to install all dependencies, then `make dev` to start development servers. See `make help` for all available commands.
|
**Using `just` (recommended):**
|
||||||
|
|
||||||
|
Install [just](https://github.com/casey/just) (`brew install just` or `cargo install just`), then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just setup # creates venv, installs Python + JS deps
|
||||||
|
just dev # starts backend + desktop app in one terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
Other useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just dev-web # backend + web app (no Tauri/Rust build)
|
||||||
|
just dev-backend # backend only
|
||||||
|
just kill # stop all dev processes
|
||||||
|
just clean-all # nuke everything and start fresh
|
||||||
|
just --list # see all available commands
|
||||||
|
```
|
||||||
|
|
||||||
|
**Using the Makefile:** Run `make setup` then `make dev`. See `make help` for all commands.
|
||||||
|
|
||||||
**Manual setup (required for Windows):**
|
**Manual setup (required for Windows):**
|
||||||
|
|
||||||
@@ -407,7 +427,7 @@ See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and sol
|
|||||||
|
|
||||||
- **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies
|
- **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies
|
||||||
- **Tauri build fails:** Ensure Rust is installed, clean build with `cd tauri/src-tauri && cargo clean`
|
- **Tauri build fails:** Ensure Rust is installed, clean build with `cd tauri/src-tauri && cargo clean`
|
||||||
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:8000/openapi.json`
|
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:17493/openapi.json`
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
|
|||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Voicebox — Local TTS Server with Web UI (CPU)
|
||||||
|
# 3-stage build: Frontend → Python deps → Runtime
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# === Stage 1: Build frontend ===
|
||||||
|
FROM oven/bun:1 AS frontend
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy workspace config and frontend source
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
COPY app/ ./app/
|
||||||
|
COPY web/ ./web/
|
||||||
|
|
||||||
|
# Strip workspaces not needed for web build, and fix trailing comma
|
||||||
|
RUN sed -i '/"tauri"/d; /"landing"/d' package.json && \
|
||||||
|
sed -i -z 's/,\n ]/\n ]/' package.json
|
||||||
|
RUN bun install --no-save
|
||||||
|
# Build frontend (skip tsc — upstream has pre-existing type errors)
|
||||||
|
RUN cd web && bunx --bun vite build
|
||||||
|
|
||||||
|
|
||||||
|
# === Stage 2: Build Python dependencies ===
|
||||||
|
FROM python:3.11-slim AS backend-builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||||
|
RUN pip install --no-cache-dir --prefix=/install \
|
||||||
|
git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
|
||||||
|
|
||||||
|
# === Stage 3: Runtime ===
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN groupadd -r voicebox && \
|
||||||
|
useradd -r -g voicebox -m -s /bin/bash voicebox
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install only runtime system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy installed Python packages from builder stage
|
||||||
|
COPY --from=backend-builder /install /usr/local
|
||||||
|
|
||||||
|
# Copy backend application code
|
||||||
|
COPY --chown=voicebox:voicebox backend/ /app/backend/
|
||||||
|
|
||||||
|
# Copy built frontend from frontend stage
|
||||||
|
COPY --from=frontend --chown=voicebox:voicebox /build/web/dist /app/frontend/
|
||||||
|
|
||||||
|
# Create data directories owned by non-root user
|
||||||
|
RUN mkdir -p /app/data/generations /app/data/profiles /app/data/cache \
|
||||||
|
&& chown -R voicebox:voicebox /app/data
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER voicebox
|
||||||
|
|
||||||
|
# Expose the API port
|
||||||
|
EXPOSE 17493
|
||||||
|
|
||||||
|
# Health check — auto-restart if the server hangs
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
|
||||||
|
CMD curl -f http://localhost:17493/health || exit 1
|
||||||
|
|
||||||
|
# Start the FastAPI server
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
|
||||||
@@ -48,6 +48,7 @@ setup-python: $(VENV)/bin/activate ## Set up Python virtual environment and depe
|
|||||||
@echo -e "$(BLUE)Installing Python dependencies...$(NC)"
|
@echo -e "$(BLUE)Installing Python dependencies...$(NC)"
|
||||||
$(PIP) install --upgrade pip
|
$(PIP) install --upgrade pip
|
||||||
$(PIP) install -r $(BACKEND_DIR)/requirements.txt
|
$(PIP) install -r $(BACKEND_DIR)/requirements.txt
|
||||||
|
$(PIP) install --no-deps chatterbox-tts
|
||||||
@if [ "$$(uname -m)" = "arm64" ] && [ "$$(uname)" = "Darwin" ]; then \
|
@if [ "$$(uname -m)" = "arm64" ] && [ "$$(uname)" = "Darwin" ]; then \
|
||||||
echo -e "$(BLUE)Detected Apple Silicon - installing MLX dependencies...$(NC)"; \
|
echo -e "$(BLUE)Detected Apple Silicon - installing MLX dependencies...$(NC)"; \
|
||||||
$(PIP) install -r $(BACKEND_DIR)/requirements-mlx.txt; \
|
$(PIP) install -r $(BACKEND_DIR)/requirements-mlx.txt; \
|
||||||
@@ -79,7 +80,11 @@ dev: ## Start backend + desktop app (parallel)
|
|||||||
@echo -e "$(YELLOW)Note: If Tauri fails, run 'make build-server' first or use separate terminals$(NC)"
|
@echo -e "$(YELLOW)Note: If Tauri fails, run 'make build-server' first or use separate terminals$(NC)"
|
||||||
@trap 'kill 0' EXIT; \
|
@trap 'kill 0' EXIT; \
|
||||||
$(MAKE) dev-backend & \
|
$(MAKE) dev-backend & \
|
||||||
sleep 2 && $(MAKE) dev-frontend & \
|
sleep 2 && if [ "$$(uname)" = "Linux" ] && lspci 2>/dev/null | grep -qi nvidia; then \
|
||||||
|
WEBKIT_DISABLE_DMABUF_RENDERER=1 $(MAKE) dev-frontend; \
|
||||||
|
else \
|
||||||
|
$(MAKE) dev-frontend; \
|
||||||
|
fi & \
|
||||||
wait
|
wait
|
||||||
|
|
||||||
dev-backend: ## Start FastAPI backend server
|
dev-backend: ## Start FastAPI backend server
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Voicebox Offline Mode Fix
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Voicebox crashes when generating speech if HuggingFace is unreachable, even when models are fully cached locally.
|
||||||
|
|
||||||
|
**Root Cause:**
|
||||||
|
- Voicebox downloads `mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16` (MLX optimized version)
|
||||||
|
- But `mlx_audio.tts.load()` tries to fetch `config.json` from original repo `Qwen/Qwen3-TTS-12Hz-1.7B-Base`
|
||||||
|
- This network request fails → server crashes with `RemoteDisconnected`
|
||||||
|
|
||||||
|
**Related Issues:**
|
||||||
|
- Issue #150: "Internet connection required, even though models are downloaded?"
|
||||||
|
- Issue #151: "API Stability Issues: Model Loading Hangs and Server Crashes"
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
Two-part fix:
|
||||||
|
|
||||||
|
### 1. Monkey-patch huggingface_hub (`backend/utils/hf_offline_patch.py`)
|
||||||
|
- Intercepts cache lookup functions
|
||||||
|
- Forces offline mode early (before mlx_audio imports)
|
||||||
|
- Adds debug logging for cache hits/misses
|
||||||
|
|
||||||
|
### 2. Symlink original repo to MLX version (`ensure_original_qwen_config_cached()`)
|
||||||
|
- When original `Qwen/Qwen3-TTS-12Hz-1.7B-Base` cache doesn't exist
|
||||||
|
- But MLX `mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16` does exist
|
||||||
|
- Creates a symlink so cache lookups succeed
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
- `backend/backends/mlx_backend.py` - Added patch imports at top
|
||||||
|
- `backend/utils/hf_offline_patch.py` - New patch module
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
To test this fix:
|
||||||
|
1. Build Voicebox from source: `make build`
|
||||||
|
2. Disconnect from internet
|
||||||
|
3. Try generating speech
|
||||||
|
4. Should work without network requests
|
||||||
|
|
||||||
|
## Build Instructions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Or build just the server
|
||||||
|
make build-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- The patch is applied automatically when `mlx_backend.py` is imported
|
||||||
|
- Set `VOICEBOX_OFFLINE_PATCH=0` to disable the patch
|
||||||
|
- The symlink approach works because the config.json is compatible between versions
|
||||||
|
|
||||||
|
---
|
||||||
|
*Patch contributed by community*
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
## What is Voicebox?
|
## What is Voicebox?
|
||||||
|
|
||||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as the **Ollama for voice** — download models, clone voices, and generate speech entirely on your machine.
|
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as a **local, free and open-source alternative to ElevenLabs** — download models, clone voices, and generate speech entirely on your machine.
|
||||||
|
|
||||||
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives you:
|
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives you:
|
||||||
|
|
||||||
@@ -80,10 +80,10 @@ Voicebox is available now for macOS and Windows.
|
|||||||
|
|
||||||
| Platform | Download |
|
| Platform | Download |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| macOS (Apple Silicon) | [voicebox_aarch64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_aarch64.app.tar.gz) |
|
| macOS (Apple Silicon) | [Voicebox_aarch64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/latest/download/Voicebox_aarch64.app.tar.gz) |
|
||||||
| macOS (Intel) | [voicebox_x64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_x64.app.tar.gz) |
|
| macOS (Intel) | [Voicebox_x64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/latest/download/Voicebox_x64.app.tar.gz) |
|
||||||
| Windows (MSI) | [voicebox_0.1.0_x64_en-US.msi](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_0.1.0_x64_en-US.msi) |
|
| Windows (MSI) | [Latest Windows MSI](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||||
| Windows (Setup) | [voicebox_0.1.0_x64-setup.exe](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_0.1.0_x64-setup.exe) |
|
| Windows (Setup) | [Latest Windows Setup](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||||
|
|
||||||
> **Linux builds coming soon** — Currently blocked by GitHub runner disk space limitations.
|
> **Linux builds coming soon** — Currently blocked by GitHub runner disk space limitations.
|
||||||
|
|
||||||
@@ -98,12 +98,12 @@ Powered by Alibaba's **Qwen3-TTS** — a breakthrough model that achieves near-p
|
|||||||
- **Instant cloning** — Upload a sample, get a voice profile
|
- **Instant cloning** — Upload a sample, get a voice profile
|
||||||
- **High fidelity** — Natural prosody, emotion, and cadence
|
- **High fidelity** — Natural prosody, emotion, and cadence
|
||||||
- **Multi-language** — English, Chinese, and more coming
|
- **Multi-language** — English, Chinese, and more coming
|
||||||
- **Lightning fast on Mac** — MLX backend leverages Apple Silicon's Neural Engine for super fast generation
|
- **Lightning fast on Mac** — MLX backend leverages Apple Silicon's Neural Engine for super-fast generation
|
||||||
|
|
||||||
### Voice Profile Management
|
### Voice Profile Management
|
||||||
|
|
||||||
- **Create profiles** from audio files or record directly in-app
|
- **Create profiles** from audio files or record directly in-app
|
||||||
- **Import/Export** profiles to share or backup
|
- **Import/Export** profiles to share or back up
|
||||||
- **Multi-sample support** — combine multiple samples for higher quality cloning
|
- **Multi-sample support** — combine multiple samples for higher quality cloning
|
||||||
- **Organize** with descriptions and language tags
|
- **Organize** with descriptions and language tags
|
||||||
|
|
||||||
@@ -147,17 +147,20 @@ Create multi-voice narratives, podcasts, and conversations with a timeline-based
|
|||||||
|
|
||||||
Voicebox exposes a full REST API, so you can integrate voice synthesis into your own apps.
|
Voicebox exposes a full REST API, so you can integrate voice synthesis into your own apps.
|
||||||
|
|
||||||
|
For the current local app and development workflow, the backend is typically available at `http://localhost:17493`.
|
||||||
|
If you launch the backend manually with a different host or port, use that address instead.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate speech
|
# Generate speech
|
||||||
curl -X POST http://localhost:8000/generate \
|
curl -X POST http://localhost:17493/generate \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
|
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
|
||||||
|
|
||||||
# List voice profiles
|
# List voice profiles
|
||||||
curl http://localhost:8000/profiles
|
curl http://localhost:17493/profiles
|
||||||
|
|
||||||
# Create a profile
|
# Create a profile
|
||||||
curl -X POST http://localhost:8000/profiles \
|
curl -X POST http://localhost:17493/profiles \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"name": "My Voice", "language": "en"}'
|
-d '{"name": "My Voice", "language": "en"}'
|
||||||
```
|
```
|
||||||
@@ -170,7 +173,7 @@ curl -X POST http://localhost:8000/profiles \
|
|||||||
- Voice assistants
|
- Voice assistants
|
||||||
- Content creation automation
|
- Content creation automation
|
||||||
|
|
||||||
Full API documentation available at `http://localhost:8000/docs` when running.
|
Full API documentation is available at `http://localhost:17493/docs` in the default local workflow, or at `/docs` on whatever server address you configured.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -225,42 +228,21 @@ Voicebox aims to be the **one-stop shop for everything voice** — cloning, synt
|
|||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup and contribution guidelines.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup and contribution guidelines.
|
||||||
|
|
||||||
**Using the Makefile (recommended):** Run `make help` to see all available commands for setup, development, building, and testing.
|
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
**With Makefile (Unix/macOS/Linux):**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repo
|
git clone https://github.com/jamiepine/voicebox.git
|
||||||
git clone https://github.com/voicebox-sh/voicebox.git
|
|
||||||
cd voicebox
|
cd voicebox
|
||||||
|
|
||||||
# Setup everything
|
just setup # creates Python venv, installs all deps
|
||||||
make setup
|
just dev # starts backend + desktop app
|
||||||
|
|
||||||
# Start development
|
|
||||||
make dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Manual setup (all platforms):**
|
Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands.
|
||||||
|
|
||||||
```bash
|
Also available via Makefile: `make setup && make dev` (run `make help` for all commands).
|
||||||
# Clone the repo
|
|
||||||
git clone https://github.com/voicebox-sh/voicebox.git
|
|
||||||
cd voicebox
|
|
||||||
|
|
||||||
# Install dependencies
|
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [XCode on macOS](https://developer.apple.com/xcode/), [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/).
|
||||||
bun install
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
cd backend && pip install -r requirements.txt && cd ..
|
|
||||||
|
|
||||||
# Start development
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org).
|
|
||||||
|
|
||||||
**Performance:**
|
**Performance:**
|
||||||
- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration for 4-5x faster inference
|
- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration for 4-5x faster inference
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.11",
|
"version": "0.1.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+19
-7
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { RouterProvider } from '@tanstack/react-router';
|
import { RouterProvider } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
import ShinyText from '@/components/ShinyText';
|
import ShinyText from '@/components/ShinyText';
|
||||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||||
|
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { router } from '@/router';
|
import { router } from '@/router';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
|
||||||
|
|
||||||
const LOADING_MESSAGES = [
|
const LOADING_MESSAGES = [
|
||||||
'Warming up tensors...',
|
'Warming up tensors...',
|
||||||
@@ -38,6 +39,9 @@ function App() {
|
|||||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||||
const serverStartingRef = useRef(false);
|
const serverStartingRef = useRef(false);
|
||||||
|
|
||||||
|
// Automatically check for app updates on startup and show toast notifications
|
||||||
|
useAutoUpdater({ checkOnMount: true, showToast: true });
|
||||||
|
|
||||||
// Sync stored setting to Rust on startup
|
// Sync stored setting to Rust on startup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (platform.metadata.isTauri) {
|
if (platform.metadata.isTauri) {
|
||||||
@@ -46,14 +50,18 @@ function App() {
|
|||||||
console.error('Failed to sync initial setting to Rust:', error);
|
console.error('Failed to sync initial setting to Rust:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [platform]);
|
// Empty dependency array - platform is stable from context, only run once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||||
|
|
||||||
// Setup lifecycle callbacks
|
// Setup lifecycle callbacks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
platform.lifecycle.onServerReady = () => {
|
platform.lifecycle.onServerReady = () => {
|
||||||
setServerReady(true);
|
setServerReady(true);
|
||||||
};
|
};
|
||||||
}, [platform]);
|
// Empty dependency array - platform is stable from context, only run once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.lifecycle]);
|
||||||
|
|
||||||
// Setup window close handler and auto-start server when running in Tauri (production only)
|
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,10 +93,12 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serverStartingRef.current = true;
|
serverStartingRef.current = true;
|
||||||
console.log('Production mode: Starting bundled server...');
|
const isRemote = useServerStore.getState().mode === 'remote';
|
||||||
|
const customModelsDir = useServerStore.getState().customModelsDir;
|
||||||
|
console.log(`Production mode: Starting bundled server... (remote: ${isRemote})`);
|
||||||
|
|
||||||
platform.lifecycle
|
platform.lifecycle
|
||||||
.startServer(false)
|
.startServer(isRemote, customModelsDir)
|
||||||
.then((serverUrl) => {
|
.then((serverUrl) => {
|
||||||
console.log('Server is ready at:', serverUrl);
|
console.log('Server is ready at:', serverUrl);
|
||||||
// Update the server URL in the store with the dynamically assigned port
|
// Update the server URL in the store with the dynamically assigned port
|
||||||
@@ -111,7 +121,9 @@ function App() {
|
|||||||
// Window close event handles server shutdown based on setting
|
// Window close event handles server shutdown based on setting
|
||||||
serverStartingRef.current = false;
|
serverStartingRef.current = false;
|
||||||
};
|
};
|
||||||
}, [platform]);
|
// Empty dependency array - platform is stable from context, only run once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||||
|
|
||||||
// Cycle through loading messages every 3 seconds
|
// Cycle through loading messages every 3 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
|
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
import { debug } from '@/lib/utils/debug';
|
import { debug } from '@/lib/utils/debug';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
export function AudioPlayer() {
|
export function AudioPlayer() {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
|
const volumeLabelId = useId();
|
||||||
const {
|
const {
|
||||||
audioUrl,
|
audioUrl,
|
||||||
audioId,
|
audioId,
|
||||||
@@ -359,7 +360,7 @@ export function AudioPlayer() {
|
|||||||
if (shouldAutoPlayNow) {
|
if (shouldAutoPlayNow) {
|
||||||
// Clear the flag first
|
// Clear the flag first
|
||||||
usePlayerStore.getState().clearAutoPlayFlag();
|
usePlayerStore.getState().clearAutoPlayFlag();
|
||||||
|
|
||||||
// Use a small delay to ensure audio element is fully ready
|
// Use a small delay to ensure audio element is fully ready
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
wavesurfer.play().catch((error) => {
|
wavesurfer.play().catch((error) => {
|
||||||
@@ -664,7 +665,7 @@ export function AudioPlayer() {
|
|||||||
// Handle shouldAutoPlay flag - for story mode auto-advance
|
// Handle shouldAutoPlay flag - for story mode auto-advance
|
||||||
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
|
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
|
||||||
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
|
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wavesurfer = wavesurferRef.current;
|
const wavesurfer = wavesurferRef.current;
|
||||||
if (!wavesurfer || !shouldAutoPlay || duration === 0) {
|
if (!wavesurfer || !shouldAutoPlay || duration === 0) {
|
||||||
@@ -831,6 +832,9 @@ export function AudioPlayer() {
|
|||||||
disabled={isLoading || duration === 0}
|
disabled={isLoading || duration === 0}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
|
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
|
||||||
|
aria-label={
|
||||||
|
duration === 0 && !isLoading ? 'Audio not loaded' : isPlaying ? 'Pause' : 'Play'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -845,6 +849,8 @@ export function AudioPlayer() {
|
|||||||
max={100}
|
max={100}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
aria-label="Playback position"
|
||||||
|
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
@@ -862,7 +868,9 @@ export function AudioPlayer() {
|
|||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
{title && (
|
{title && (
|
||||||
<div className="text-sm font-medium truncate max-w-[200px] shrink-0">{title}</div>
|
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loop Button */}
|
{/* Loop Button */}
|
||||||
@@ -872,26 +880,37 @@ export function AudioPlayer() {
|
|||||||
onClick={toggleLoop}
|
onClick={toggleLoop}
|
||||||
className={isLooping ? 'text-primary' : ''}
|
className={isLooping ? 'text-primary' : ''}
|
||||||
title="Toggle loop"
|
title="Toggle loop"
|
||||||
|
aria-label={isLooping ? 'Stop looping' : 'Loop'}
|
||||||
>
|
>
|
||||||
<Repeat className="h-4 w-4" />
|
<Repeat className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Volume Control */}
|
{/* Volume Control */}
|
||||||
<div className="flex items-center gap-2 shrink-0 w-[120px]">
|
<div
|
||||||
|
className="flex items-center gap-2 shrink-0 w-[120px]"
|
||||||
|
role="group"
|
||||||
|
aria-label="Volume"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setVolume(volume > 0 ? 0 : 1)}
|
onClick={() => setVolume(volume > 0 ? 0 : 1)}
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
|
aria-label={volume > 0 ? 'Mute' : 'Unmute'}
|
||||||
>
|
>
|
||||||
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
<span id={volumeLabelId} className="sr-only">
|
||||||
|
Volume level, {Math.round(volume * 100)}%
|
||||||
|
</span>
|
||||||
<Slider
|
<Slider
|
||||||
value={[volume * 100]}
|
value={[volume * 100]}
|
||||||
onValueChange={handleVolumeChange}
|
onValueChange={handleVolumeChange}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
aria-labelledby={volumeLabelId}
|
||||||
|
aria-valuetext={`${Math.round(volume * 100)}%`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -902,6 +921,7 @@ export function AudioPlayer() {
|
|||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
title="Close player"
|
title="Close player"
|
||||||
|
aria-label="Close player"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import {
|
|||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -124,6 +124,13 @@ export function AudioTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleChannelDelete = async (e, channelId) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (await confirm('Delete this channel?')) {
|
||||||
|
deleteChannel.mutate(channelId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const allChannels = channels || [];
|
const allChannels = channels || [];
|
||||||
const allDevices = devices || [];
|
const allDevices = devices || [];
|
||||||
const selectedChannel = selectedChannelId
|
const selectedChannel = selectedChannelId
|
||||||
@@ -161,7 +168,7 @@ export function AudioTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 p-2">
|
<div className="space-y-3">
|
||||||
{allChannels.map((channel) => {
|
{allChannels.map((channel) => {
|
||||||
const isSelected = selectedChannelId === channel.id;
|
const isSelected = selectedChannelId === channel.id;
|
||||||
return (
|
return (
|
||||||
@@ -241,12 +248,7 @@ export function AudioTab() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
onClick={(e) => {
|
onClick={(e) => handleChannelDelete(e, channel.id)}
|
||||||
e.stopPropagation();
|
|
||||||
if (confirm('Delete this channel?')) {
|
|
||||||
deleteChannel.mutate(channel.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -341,7 +343,9 @@ export function AudioTab() {
|
|||||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||||
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
<p className="text-muted-foreground text-center">
|
<p className="text-muted-foreground text-center">
|
||||||
{platform.metadata.isTauri ? 'No audio devices found' : 'Audio device selection requires Tauri'}
|
{platform.metadata.isTauri
|
||||||
|
? 'No audio devices found'
|
||||||
|
: 'Audio device selection requires Tauri'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMatchRoute } from '@tanstack/react-router';
|
import { useMatchRoute } from '@tanstack/react-router';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Loader2, MessageSquare, Sparkles } from 'lucide-react';
|
import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
@@ -12,14 +12,15 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
|
||||||
import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
|
|
||||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||||
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
import { useAddStoryItem, useStory } from '@/lib/hooks/useStories';
|
import { useStory } from '@/lib/hooks/useStories';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { ParalinguisticInput } from './ParalinguisticInput';
|
||||||
|
|
||||||
interface FloatingGenerateBoxProps {
|
interface FloatingGenerateBoxProps {
|
||||||
isPlayerOpen?: boolean;
|
isPlayerOpen?: boolean;
|
||||||
@@ -43,8 +44,7 @@ export function FloatingGenerateBox({
|
|||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
const { data: currentStory } = useStory(selectedStoryId);
|
const { data: currentStory } = useStory(selectedStoryId);
|
||||||
const addStoryItem = useAddStoryItem();
|
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// Calculate if track editor is visible (on stories route with items)
|
// Calculate if track editor is visible (on stories route with items)
|
||||||
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
||||||
@@ -52,25 +52,9 @@ export function FloatingGenerateBox({
|
|||||||
const { form, handleSubmit, isPending } = useGenerationForm({
|
const { form, handleSubmit, isPending } = useGenerationForm({
|
||||||
onSuccess: async (generationId) => {
|
onSuccess: async (generationId) => {
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
// If on stories route and a story is selected, add generation to story
|
// Defer the story add until TTS completes — useGenerationProgress handles it
|
||||||
if (isStoriesRoute && selectedStoryId && generationId) {
|
if (isStoriesRoute && selectedStoryId && generationId) {
|
||||||
try {
|
addPendingStoryAdd(generationId, selectedStoryId);
|
||||||
await addStoryItem.mutateAsync({
|
|
||||||
storyId: selectedStoryId,
|
|
||||||
data: { generation_id: generationId },
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: 'Added to story',
|
|
||||||
description: `Generation added to "${currentStory?.name || 'story'}"`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to add to story',
|
|
||||||
description:
|
|
||||||
error instanceof Error ? error.message : 'Could not add generation to story',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -112,6 +96,13 @@ export function FloatingGenerateBox({
|
|||||||
}
|
}
|
||||||
}, [selectedProfileId, profiles, setSelectedProfileId]);
|
}, [selectedProfileId, profiles, setSelectedProfileId]);
|
||||||
|
|
||||||
|
// Sync generation form language with selected profile's language
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProfile?.language) {
|
||||||
|
form.setValue('language', selectedProfile.language as LanguageCode);
|
||||||
|
}
|
||||||
|
}, [selectedProfile, form]);
|
||||||
|
|
||||||
// Auto-resize textarea based on content (only when expanded)
|
// Auto-resize textarea based on content (only when expanded)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
@@ -174,7 +165,7 @@ export function FloatingGenerateBox({
|
|||||||
isStoriesRoute
|
isStoriesRoute
|
||||||
? // Position aligned with story list: after sidebar + padding, width 360px
|
? // Position aligned with story list: after sidebar + padding, width 360px
|
||||||
'left-[calc(5rem+2rem)] w-[360px]'
|
'left-[calc(5rem+2rem)] w-[360px]'
|
||||||
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
|
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
// On stories route: offset by track editor height when visible
|
// On stories route: offset by track editor height when visible
|
||||||
@@ -187,7 +178,7 @@ export function FloatingGenerateBox({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 overflow-hidden p-3"
|
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 p-3"
|
||||||
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
||||||
>
|
>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -212,34 +203,57 @@ export function FloatingGenerateBox({
|
|||||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
style={{ overflow: 'hidden' }}
|
style={{ overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
<Textarea
|
{form.watch('engine') === 'chatterbox_turbo' ? (
|
||||||
{...field}
|
<ParalinguisticInput
|
||||||
ref={(node: HTMLTextAreaElement | null) => {
|
value={field.value}
|
||||||
// Store ref for auto-resize (only for active field)
|
onChange={field.onChange}
|
||||||
if (!isInstructMode) {
|
placeholder={
|
||||||
textareaRef.current = node;
|
isStoriesRoute && currentStory
|
||||||
|
? `Generate speech for "${currentStory.name}"... (type / for effects)`
|
||||||
|
: selectedProfile
|
||||||
|
? `Type / for effects like [laugh], [sigh]...`
|
||||||
|
: 'Select a voice profile above...'
|
||||||
}
|
}
|
||||||
// Forward ref to react-hook-form
|
className="px-3 py-2 resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm w-full"
|
||||||
if (typeof field.ref === 'function') {
|
style={{
|
||||||
field.ref(node);
|
minHeight: isExpanded ? '100px' : '32px',
|
||||||
|
maxHeight: '300px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
disabled={!selectedProfileId}
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
onFocus={() => setIsExpanded(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
ref={(node: HTMLTextAreaElement | null) => {
|
||||||
|
// Store ref for auto-resize (only for active field)
|
||||||
|
if (!isInstructMode) {
|
||||||
|
textareaRef.current = node;
|
||||||
|
}
|
||||||
|
// Forward ref to react-hook-form
|
||||||
|
if (typeof field.ref === 'function') {
|
||||||
|
field.ref(node);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
isStoriesRoute && currentStory
|
||||||
|
? `Generate speech for "${currentStory.name}"...`
|
||||||
|
: selectedProfile
|
||||||
|
? `Generate speech using ${selectedProfile.name}...`
|
||||||
|
: 'Select a voice profile above...'
|
||||||
}
|
}
|
||||||
}}
|
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"
|
||||||
placeholder={
|
style={{
|
||||||
isStoriesRoute && currentStory
|
minHeight: isExpanded ? '100px' : '32px',
|
||||||
? `Generate speech for "${currentStory.name}"...`
|
maxHeight: '300px',
|
||||||
: selectedProfile
|
}}
|
||||||
? `Generate speech using ${selectedProfile.name}...`
|
disabled={!selectedProfileId}
|
||||||
: 'Select a voice profile above...'
|
onClick={() => setIsExpanded(true)}
|
||||||
}
|
onFocus={() => setIsExpanded(true)}
|
||||||
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
/>
|
||||||
style={{
|
)}
|
||||||
minHeight: isExpanded ? '100px' : '32px',
|
|
||||||
maxHeight: '300px',
|
|
||||||
}}
|
|
||||||
disabled={!selectedProfileId}
|
|
||||||
onClick={() => setIsExpanded(true)}
|
|
||||||
onFocus={() => setIsExpanded(true)}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage className="text-xs" />
|
<FormMessage className="text-xs" />
|
||||||
@@ -274,7 +288,7 @@ export function FloatingGenerateBox({
|
|||||||
field.ref(node);
|
field.ref(node);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Add delivery instructions..."
|
placeholder="e.g. very happy and excited"
|
||||||
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
||||||
style={{
|
style={{
|
||||||
minHeight: isExpanded ? '100px' : '32px',
|
minHeight: isExpanded ? '100px' : '32px',
|
||||||
@@ -294,20 +308,36 @@ export function FloatingGenerateBox({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<Button
|
<div className="group relative">
|
||||||
type="submit"
|
<Button
|
||||||
disabled={isPending || !selectedProfileId}
|
type="submit"
|
||||||
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"
|
disabled={isPending || !selectedProfileId}
|
||||||
size="icon"
|
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 ? (
|
aria-label={
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
isPending
|
||||||
) : (
|
? 'Generating...'
|
||||||
<Sparkles className="h-4 w-4" />
|
: !selectedProfileId
|
||||||
)}
|
? 'Select a voice profile first'
|
||||||
</Button>
|
: 'Generate speech'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||||
|
{isPending
|
||||||
|
? 'Generating...'
|
||||||
|
: !selectedProfileId
|
||||||
|
? 'Select a voice profile first'
|
||||||
|
: 'Generate speech'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isExpanded && (
|
{isExpanded && form.watch('engine') === 'qwen' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
@@ -315,20 +345,28 @@ export function FloatingGenerateBox({
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="absolute top-0 right-[calc(100%+0.5rem)]"
|
className="absolute top-0 right-[calc(100%+0.5rem)]"
|
||||||
>
|
>
|
||||||
<Button
|
<div className="group relative">
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="icon"
|
variant="ghost"
|
||||||
onClick={() => setIsInstructMode(!isInstructMode)}
|
size="icon"
|
||||||
className={cn(
|
onClick={() => setIsInstructMode(!isInstructMode)}
|
||||||
'h-10 w-10 rounded-full transition-all duration-200',
|
className={cn(
|
||||||
isInstructMode
|
'h-10 w-10 rounded-full transition-all duration-200',
|
||||||
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
isInstructMode
|
||||||
: 'bg-card border border-border hover:bg-background/50',
|
? '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" />
|
aria-label={
|
||||||
</Button>
|
isInstructMode ? 'Fine tune instructions, on' : 'Fine tune instructions'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||||
|
Fine tune instructions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -367,51 +405,86 @@ export function FloatingGenerateBox({
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="language"
|
name="language"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem className="flex-1 space-y-0">
|
const engineLangs = getLanguageOptionsForEngine(
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
form.watch('engine') || 'qwen',
|
||||||
<FormControl>
|
);
|
||||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
return (
|
||||||
<SelectValue />
|
<FormItem className="flex-1 space-y-0">
|
||||||
</SelectTrigger>
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
<SelectContent>
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||||
{LANGUAGE_OPTIONS.map((lang) => (
|
<SelectValue />
|
||||||
<SelectItem key={lang.value} value={lang.value} className="text-xs">
|
</SelectTrigger>
|
||||||
{lang.label}
|
</FormControl>
|
||||||
</SelectItem>
|
<SelectContent>
|
||||||
))}
|
{engineLangs.map((lang) => (
|
||||||
</SelectContent>
|
<SelectItem key={lang.value} value={lang.value} className="text-xs">
|
||||||
</Select>
|
{lang.label}
|
||||||
<FormMessage className="text-xs" />
|
</SelectItem>
|
||||||
</FormItem>
|
))}
|
||||||
)}
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage className="text-xs" />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormItem className="flex-1 space-y-0">
|
||||||
control={form.control}
|
<Select
|
||||||
name="modelSize"
|
value={
|
||||||
render={({ field }) => (
|
form.watch('engine') === 'luxtts'
|
||||||
<FormItem className="flex-1 space-y-0">
|
? 'luxtts'
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
: form.watch('engine') === 'chatterbox'
|
||||||
<FormControl>
|
? 'chatterbox'
|
||||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
: form.watch('engine') === 'chatterbox_turbo'
|
||||||
<SelectValue />
|
? 'chatterbox_turbo'
|
||||||
</SelectTrigger>
|
: `qwen:${form.watch('modelSize') || '1.7B'}`
|
||||||
</FormControl>
|
}
|
||||||
<SelectContent>
|
onValueChange={(value) => {
|
||||||
<SelectItem value="1.7B" className="text-xs text-muted-foreground">
|
if (value === 'luxtts') {
|
||||||
Qwen3-TTS 1.7B
|
form.setValue('engine', 'luxtts');
|
||||||
</SelectItem>
|
form.setValue('language', 'en');
|
||||||
<SelectItem value="0.6B" className="text-xs text-muted-foreground">
|
} else if (value === 'chatterbox') {
|
||||||
Qwen3-TTS 0.6B
|
form.setValue('engine', 'chatterbox');
|
||||||
</SelectItem>
|
} else if (value === 'chatterbox_turbo') {
|
||||||
</SelectContent>
|
form.setValue('engine', 'chatterbox_turbo');
|
||||||
</Select>
|
form.setValue('language', 'en');
|
||||||
<FormMessage className="text-xs" />
|
} else {
|
||||||
</FormItem>
|
const [, modelSize] = value.split(':');
|
||||||
)}
|
form.setValue('engine', 'qwen');
|
||||||
/>
|
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="qwen:1.7B" className="text-xs text-muted-foreground">
|
||||||
|
Qwen3-TTS 1.7B
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="qwen:0.6B" className="text-xs text-muted-foreground">
|
||||||
|
Qwen3-TTS 0.6B
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="luxtts" className="text-xs text-muted-foreground">
|
||||||
|
LuxTTS
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="chatterbox" className="text-xs text-muted-foreground">
|
||||||
|
Chatterbox
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="chatterbox_turbo"
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Chatterbox Turbo
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormItem>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
|
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
|
||||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { ParalinguisticInput } from './ParalinguisticInput';
|
||||||
|
|
||||||
export function GenerationForm() {
|
export function GenerationForm() {
|
||||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||||
@@ -64,87 +65,134 @@ export function GenerationForm() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Text to Speak</FormLabel>
|
<FormLabel>Text to Speak</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
{form.watch('engine') === 'chatterbox_turbo' ? (
|
||||||
placeholder="Enter the text you want to generate..."
|
<ParalinguisticInput
|
||||||
className="min-h-[150px]"
|
value={field.value}
|
||||||
{...field}
|
onChange={field.onChange}
|
||||||
/>
|
placeholder="Enter text... type / for effects like [laugh], [sigh]"
|
||||||
</FormControl>
|
className="min-h-[150px] rounded-md border border-input bg-background px-3 py-2"
|
||||||
<FormDescription>Max 5000 characters</FormDescription>
|
/>
|
||||||
<FormMessage />
|
) : (
|
||||||
</FormItem>
|
<Textarea
|
||||||
)}
|
placeholder="Enter the text you want to generate..."
|
||||||
/>
|
className="min-h-[150px]"
|
||||||
|
{...field}
|
||||||
<FormField
|
/>
|
||||||
control={form.control}
|
)}
|
||||||
name="instruct"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Delivery Instructions (optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
|
|
||||||
className="min-h-[80px]"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Natural language instructions to control speech delivery (tone, emotion, pace).
|
{form.watch('engine') === 'chatterbox_turbo'
|
||||||
Max 500 characters
|
? 'Max 5000 characters. Type / to insert sound effects.'
|
||||||
|
: 'Max 5000 characters'}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
{form.watch('engine') === 'qwen' && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="language"
|
name="instruct"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Language</FormLabel>
|
<FormLabel>Delivery Instructions (optional)</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<FormControl>
|
||||||
<FormControl>
|
<Textarea
|
||||||
<SelectTrigger>
|
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
|
||||||
<SelectValue />
|
className="min-h-[80px]"
|
||||||
</SelectTrigger>
|
{...field}
|
||||||
</FormControl>
|
/>
|
||||||
<SelectContent>
|
</FormControl>
|
||||||
{LANGUAGE_OPTIONS.map((lang) => (
|
<FormDescription>
|
||||||
<SelectItem key={lang.value} value={lang.value}>
|
Natural language instructions to control speech delivery (tone, emotion,
|
||||||
{lang.label}
|
pace). Max 500 characters
|
||||||
</SelectItem>
|
</FormDescription>
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Model</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
form.watch('engine') === 'luxtts'
|
||||||
|
? 'luxtts'
|
||||||
|
: form.watch('engine') === 'chatterbox'
|
||||||
|
? 'chatterbox'
|
||||||
|
: form.watch('engine') === 'chatterbox_turbo'
|
||||||
|
? 'chatterbox_turbo'
|
||||||
|
: `qwen:${form.watch('modelSize') || '1.7B'}`
|
||||||
|
}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === 'luxtts') {
|
||||||
|
form.setValue('engine', 'luxtts');
|
||||||
|
form.setValue('language', 'en');
|
||||||
|
} else if (value === 'chatterbox') {
|
||||||
|
form.setValue('engine', 'chatterbox');
|
||||||
|
} else if (value === 'chatterbox_turbo') {
|
||||||
|
form.setValue('engine', 'chatterbox_turbo');
|
||||||
|
form.setValue('language', 'en');
|
||||||
|
} else {
|
||||||
|
const [, modelSize] = value.split(':');
|
||||||
|
form.setValue('engine', 'qwen');
|
||||||
|
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="qwen:1.7B">Qwen3-TTS 1.7B</SelectItem>
|
||||||
|
<SelectItem value="qwen:0.6B">Qwen3-TTS 0.6B</SelectItem>
|
||||||
|
<SelectItem value="luxtts">LuxTTS</SelectItem>
|
||||||
|
<SelectItem value="chatterbox">Chatterbox</SelectItem>
|
||||||
|
<SelectItem value="chatterbox_turbo">Chatterbox Turbo</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
{form.watch('engine') === 'luxtts'
|
||||||
|
? 'Fast, English-focused'
|
||||||
|
: form.watch('engine') === 'chatterbox'
|
||||||
|
? '23 languages, incl. Hebrew'
|
||||||
|
: form.watch('engine') === 'chatterbox_turbo'
|
||||||
|
? 'English, [laugh] [cough] tags'
|
||||||
|
: 'Multi-language, two sizes'}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="modelSize"
|
name="language"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem>
|
const engineLangs = getLanguageOptionsForEngine(form.watch('engine') || 'qwen');
|
||||||
<FormLabel>Model Size</FormLabel>
|
return (
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Language</FormLabel>
|
||||||
<SelectTrigger>
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
<SelectValue />
|
<FormControl>
|
||||||
</SelectTrigger>
|
<SelectTrigger>
|
||||||
</FormControl>
|
<SelectValue />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
<SelectItem value="1.7B">Qwen TTS 1.7B (Higher Quality)</SelectItem>
|
</FormControl>
|
||||||
<SelectItem value="0.6B">Qwen TTS 0.6B (Faster)</SelectItem>
|
<SelectContent>
|
||||||
</SelectContent>
|
{engineLangs.map((lang) => (
|
||||||
</Select>
|
<SelectItem key={lang.value} value={lang.value}>
|
||||||
<FormDescription>Larger models produce better quality</FormDescription>
|
{lang.label}
|
||||||
<FormMessage />
|
</SelectItem>
|
||||||
</FormItem>
|
))}
|
||||||
)}
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -170,11 +218,7 @@ export function GenerationForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button type="submit" className="w-full" disabled={isPending || !selectedProfileId}>
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
disabled={isPending || !selectedProfileId}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
/**
|
||||||
|
* ParalinguisticInput — a contentEditable rich text input that renders
|
||||||
|
* Chatterbox Turbo paralinguistic tags (e.g. [laugh]) as inline badges.
|
||||||
|
*
|
||||||
|
* Trigger: typing "/" opens an autocomplete dropdown.
|
||||||
|
* Paste: pasting text with [tag] patterns auto-converts to badges.
|
||||||
|
* Output: serializes badges back to plain [tag] text for the API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
// ── Tag definitions ─────────────────────────────────────────────────
|
||||||
|
const PARALINGUISTIC_TAGS = [
|
||||||
|
{ tag: '[laugh]', label: 'laugh', emoji: '\u{1F602}' },
|
||||||
|
{ tag: '[chuckle]', label: 'chuckle', emoji: '\u{1F60F}' },
|
||||||
|
{ tag: '[gasp]', label: 'gasp', emoji: '\u{1F62E}' },
|
||||||
|
{ tag: '[cough]', label: 'cough', emoji: '\u{1F637}' },
|
||||||
|
{ tag: '[sigh]', label: 'sigh', emoji: '\u{1F614}' },
|
||||||
|
{ tag: '[groan]', label: 'groan', emoji: '\u{1F629}' },
|
||||||
|
{ tag: '[sniff]', label: 'sniff', emoji: '\u{1F443}' },
|
||||||
|
{ tag: '[shush]', label: 'shush', emoji: '\u{1F92B}' },
|
||||||
|
{ tag: '[clear throat]', label: 'clear throat', emoji: '\u{1F64A}' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TAG_REGEX = /\[(laugh|chuckle|gasp|cough|sigh|groan|sniff|shush|clear throat)\]/gi;
|
||||||
|
|
||||||
|
// Data attribute used to identify badge spans in the DOM
|
||||||
|
const BADGE_ATTR = 'data-ptag';
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Build an inline badge <span> for a tag. */
|
||||||
|
function makeBadgeHTML(tag: string): string {
|
||||||
|
const entry = PARALINGUISTIC_TAGS.find((t) => t.tag.toLowerCase() === tag.toLowerCase());
|
||||||
|
const label = entry?.label ?? tag.replace(/[[\]]/g, '');
|
||||||
|
const emoji = entry?.emoji ?? '';
|
||||||
|
// Non-editable inline badge. Zero-width spaces around it let the
|
||||||
|
// caret sit on either side so the user can type before/after.
|
||||||
|
return `\u200B<span ${BADGE_ATTR}="${tag}" contenteditable="false" class="ptag-badge">${emoji ? `${emoji}\u00A0` : ''}${label}</span>\u200B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert plain text with [tag] patterns into HTML with badge spans. */
|
||||||
|
function textToHTML(text: string): string {
|
||||||
|
// Escape HTML entities first
|
||||||
|
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
// Replace tag patterns with badge HTML
|
||||||
|
return escaped.replace(TAG_REGEX, (match) => makeBadgeHTML(match));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize the contentEditable innerHTML back to plain text with [tag] syntax. */
|
||||||
|
function htmlToText(container: HTMLElement): string {
|
||||||
|
let result = '';
|
||||||
|
for (const node of container.childNodes) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
// Strip zero-width spaces we added around badges
|
||||||
|
result += (node.textContent ?? '').replace(/\u200B/g, '');
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
if (el.hasAttribute(BADGE_ATTR)) {
|
||||||
|
result += el.getAttribute(BADGE_ATTR) ?? '';
|
||||||
|
} else if (el.tagName === 'BR') {
|
||||||
|
result += '\n';
|
||||||
|
} else {
|
||||||
|
// Recurse for nested elements (e.g. spans from paste)
|
||||||
|
result += htmlToText(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the text content from the current caret position back to the last
|
||||||
|
* whitespace or start of container, to detect the "/" trigger. */
|
||||||
|
function getWordBeforeCaret(_container: HTMLElement): { word: string; range: Range | null } {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || sel.rangeCount === 0) return { word: '', range: null };
|
||||||
|
const range = sel.getRangeAt(0).cloneRange();
|
||||||
|
range.collapse(true);
|
||||||
|
|
||||||
|
// Walk backwards from caret through the text node
|
||||||
|
const textNode = range.startContainer;
|
||||||
|
if (textNode.nodeType !== Node.TEXT_NODE) return { word: '', range: null };
|
||||||
|
const text = textNode.textContent ?? '';
|
||||||
|
const offset = range.startOffset;
|
||||||
|
|
||||||
|
let start = offset;
|
||||||
|
while (
|
||||||
|
start > 0 &&
|
||||||
|
text[start - 1] !== ' ' &&
|
||||||
|
text[start - 1] !== '\n' &&
|
||||||
|
text[start - 1] !== '\u00A0'
|
||||||
|
) {
|
||||||
|
start--;
|
||||||
|
}
|
||||||
|
|
||||||
|
const word = text.slice(start, offset);
|
||||||
|
const wordRange = document.createRange();
|
||||||
|
wordRange.setStart(textNode, start);
|
||||||
|
wordRange.setEnd(textNode, offset);
|
||||||
|
|
||||||
|
return { word, range: wordRange };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ParalinguisticInputProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
onClick?: () => void;
|
||||||
|
onFocus?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParalinguisticInputRef {
|
||||||
|
focus: () => void;
|
||||||
|
element: HTMLDivElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParalinguisticInput = forwardRef<ParalinguisticInputRef, ParalinguisticInputProps>(
|
||||||
|
function ParalinguisticInput(
|
||||||
|
{ value, onChange, placeholder, disabled, className, style, onClick, onFocus },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
const [menuFilter, setMenuFilter] = useState('');
|
||||||
|
const [menuIndex, setMenuIndex] = useState(0);
|
||||||
|
const [menuPosition, setMenuPosition] = useState<{ bottom: number; left: number }>({
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
});
|
||||||
|
const triggerRangeRef = useRef<Range | null>(null);
|
||||||
|
const lastSerializedRef = useRef<string>('');
|
||||||
|
const isComposingRef = useRef(false);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focus: () => editorRef.current?.focus(),
|
||||||
|
element: editorRef.current,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Filtered tag list for the autocomplete menu
|
||||||
|
const filteredTags = PARALINGUISTIC_TAGS.filter((t) =>
|
||||||
|
t.label.toLowerCase().includes(menuFilter.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Sync external value → editor ──────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
// Only update DOM if the external value differs from what we last emitted
|
||||||
|
if (value !== undefined && value !== lastSerializedRef.current) {
|
||||||
|
lastSerializedRef.current = value;
|
||||||
|
el.innerHTML = value ? textToHTML(value) : '';
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// ── Emit plain-text value on input ────────────────────────────
|
||||||
|
const emitChange = useCallback(() => {
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el || !onChange) return;
|
||||||
|
const text = htmlToText(el);
|
||||||
|
lastSerializedRef.current = text;
|
||||||
|
onChange(text);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
// ── Insert a tag badge at the caret ───────────────────────────
|
||||||
|
const insertTag = useCallback(
|
||||||
|
(tag: string) => {
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// Delete the /filter text
|
||||||
|
const wordRange = triggerRangeRef.current;
|
||||||
|
if (wordRange) {
|
||||||
|
wordRange.deleteContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert badge HTML
|
||||||
|
const temp = document.createElement('span');
|
||||||
|
temp.innerHTML = makeBadgeHTML(tag);
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
let lastNode: Node | null = null;
|
||||||
|
while (temp.firstChild) {
|
||||||
|
lastNode = frag.appendChild(temp.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && sel.rangeCount > 0) {
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(frag);
|
||||||
|
|
||||||
|
// Move caret after the badge
|
||||||
|
if (lastNode) {
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStartAfter(lastNode);
|
||||||
|
newRange.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(newRange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowMenu(false);
|
||||||
|
setMenuFilter('');
|
||||||
|
emitChange();
|
||||||
|
el.focus();
|
||||||
|
},
|
||||||
|
[emitChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Handle keydown for autocomplete navigation ────────────────
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (showMenu) {
|
||||||
|
if (filteredTags.length === 0) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setMenuIndex((i) => (i + 1) % filteredTags.length);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setMenuIndex((i) => (i - 1 + filteredTags.length) % filteredTags.length);
|
||||||
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filteredTags[menuIndex]) {
|
||||||
|
insertTag(filteredTags[menuIndex].tag);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Prevent Enter from creating <div> blocks in contentEditable
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
// Let the form handle submit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showMenu, filteredTags, menuIndex, insertTag],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Handle input (check for / trigger) ────────────────────────
|
||||||
|
const handleInput = useCallback(() => {
|
||||||
|
if (isComposingRef.current) return;
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const { word, range } = getWordBeforeCaret(el);
|
||||||
|
|
||||||
|
if (word.startsWith('/')) {
|
||||||
|
const filter = word.slice(1); // strip the /
|
||||||
|
setMenuFilter(filter);
|
||||||
|
setMenuIndex(0);
|
||||||
|
triggerRangeRef.current = range;
|
||||||
|
|
||||||
|
// Position the menu above the caret using viewport coords (portalled)
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && sel.rangeCount > 0) {
|
||||||
|
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||||
|
setMenuPosition({
|
||||||
|
bottom: window.innerHeight - rect.top + 4,
|
||||||
|
left: rect.left,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowMenu(true);
|
||||||
|
} else {
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitChange();
|
||||||
|
}, [emitChange]);
|
||||||
|
|
||||||
|
// ── Handle paste — convert [tag] patterns to badges ───────────
|
||||||
|
const handlePaste = useCallback(
|
||||||
|
(e: React.ClipboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = e.clipboardData.getData('text/plain');
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const html = textToHTML(text);
|
||||||
|
|
||||||
|
// Insert at caret
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && sel.rangeCount > 0) {
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = html;
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
let lastNode: Node | null = null;
|
||||||
|
while (temp.firstChild) {
|
||||||
|
lastNode = frag.appendChild(temp.firstChild);
|
||||||
|
}
|
||||||
|
range.insertNode(frag);
|
||||||
|
if (lastNode) {
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStartAfter(lastNode);
|
||||||
|
newRange.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(newRange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitChange();
|
||||||
|
},
|
||||||
|
[emitChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Show placeholder ──────────────────────────────────────────
|
||||||
|
const isEmpty = !value || value.trim() === '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Placeholder */}
|
||||||
|
{isEmpty && placeholder && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 text-sm text-muted-foreground/60 px-3 py-2 select-none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editable area */}
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable={!disabled}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
role={disabled ? undefined : 'textbox'}
|
||||||
|
aria-multiline={disabled ? undefined : true}
|
||||||
|
aria-placeholder={placeholder}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
className={cn(
|
||||||
|
'min-h-[32px] text-sm whitespace-pre-wrap break-words outline-none',
|
||||||
|
'[&_.ptag-badge]:inline-flex [&_.ptag-badge]:items-center [&_.ptag-badge]:rounded-full',
|
||||||
|
'[&_.ptag-badge]:bg-accent/20 [&_.ptag-badge]:text-accent [&_.ptag-badge]:border [&_.ptag-badge]:border-accent/30',
|
||||||
|
'[&_.ptag-badge]:px-2 [&_.ptag-badge]:py-0 [&_.ptag-badge]:text-xs [&_.ptag-badge]:font-medium',
|
||||||
|
'[&_.ptag-badge]:mx-0.5 [&_.ptag-badge]:select-none [&_.ptag-badge]:cursor-default',
|
||||||
|
'[&_.ptag-badge]:align-baseline',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
onInput={!disabled ? handleInput : undefined}
|
||||||
|
onKeyDown={!disabled ? handleKeyDown : undefined}
|
||||||
|
onPaste={!disabled ? handlePaste : undefined}
|
||||||
|
onClick={!disabled ? onClick : undefined}
|
||||||
|
onFocus={!disabled ? onFocus : undefined}
|
||||||
|
onBlur={() => {
|
||||||
|
setShowMenu(false);
|
||||||
|
triggerRangeRef.current = null;
|
||||||
|
}}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
isComposingRef.current = true;
|
||||||
|
}}
|
||||||
|
onCompositionEnd={() => {
|
||||||
|
isComposingRef.current = false;
|
||||||
|
handleInput();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Autocomplete dropdown — portalled to body, positioned above the caret */}
|
||||||
|
{showMenu &&
|
||||||
|
filteredTags.length > 0 &&
|
||||||
|
createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 4 }}
|
||||||
|
transition={{ duration: 0.12 }}
|
||||||
|
className="fixed z-[9999] min-w-[200px] max-h-[280px] overflow-y-auto rounded-lg border border-border bg-popover shadow-lg"
|
||||||
|
style={{
|
||||||
|
bottom: menuPosition.bottom,
|
||||||
|
left: menuPosition.left,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredTags.map((t, i) => (
|
||||||
|
<button
|
||||||
|
key={t.tag}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left transition-colors',
|
||||||
|
i === menuIndex
|
||||||
|
? 'bg-accent/20 text-accent-foreground'
|
||||||
|
: 'text-popover-foreground hover:bg-muted/50',
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault(); // Keep focus in editor
|
||||||
|
insertTag(t.tag);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setMenuIndex(i)}
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">{t.emoji}</span>
|
||||||
|
<span>{t.label}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground font-mono">{t.tag}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import { AudioWaveform, Download, FileArchive, Loader2, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
FileArchive,
|
||||||
|
Loader2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { HistoryResponse } from '@/lib/api/types';
|
import Loader from 'react-loaders';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -19,6 +28,7 @@ import {
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { HistoryResponse } from '@/lib/api/types';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import {
|
import {
|
||||||
useDeleteGeneration,
|
useDeleteGeneration,
|
||||||
@@ -28,7 +38,8 @@ import {
|
|||||||
useImportGeneration,
|
useImportGeneration,
|
||||||
} from '@/lib/hooks/useHistory';
|
} from '@/lib/hooks/useHistory';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { formatDate, formatDuration } from '@/lib/utils/format';
|
import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
||||||
@@ -46,11 +57,18 @@ export function HistoryTable() {
|
|||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(null);
|
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: historyData, isLoading, isFetching } = useHistory({
|
const {
|
||||||
|
data: historyData,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useHistory({
|
||||||
limit,
|
limit,
|
||||||
offset: page * limit,
|
offset: page * limit,
|
||||||
});
|
});
|
||||||
@@ -59,6 +77,7 @@ export function HistoryTable() {
|
|||||||
const exportGeneration = useExportGeneration();
|
const exportGeneration = useExportGeneration();
|
||||||
const exportGenerationAudio = useExportGenerationAudio();
|
const exportGenerationAudio = useExportGenerationAudio();
|
||||||
const importGeneration = useImportGeneration();
|
const importGeneration = useImportGeneration();
|
||||||
|
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||||
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||||
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
||||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||||
@@ -182,6 +201,20 @@ export function HistoryTable() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRetry = async (generationId: string) => {
|
||||||
|
try {
|
||||||
|
const result = await apiClient.retryGeneration(generationId);
|
||||||
|
addPendingGeneration(result.id);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Retry failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Could not retry generation',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleImportConfirm = () => {
|
const handleImportConfirm = () => {
|
||||||
if (selectedFile) {
|
if (selectedFile) {
|
||||||
importGeneration.mutate(selectedFile, {
|
importGeneration.mutate(selectedFile, {
|
||||||
@@ -238,25 +271,54 @@ export function HistoryTable() {
|
|||||||
>
|
>
|
||||||
{history.map((gen) => {
|
{history.map((gen) => {
|
||||||
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
||||||
|
const isGenerating = gen.status === 'generating';
|
||||||
|
const isFailed = gen.status === 'failed';
|
||||||
|
const isPlayable = !isGenerating && !isFailed;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={gen.id}
|
key={gen.id}
|
||||||
|
role={isPlayable ? 'button' : undefined}
|
||||||
|
tabIndex={isPlayable ? 0 : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors text-left w-full',
|
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card transition-colors text-left w-full',
|
||||||
|
isPlayable && 'hover:bg-muted/70 cursor-pointer',
|
||||||
isCurrentlyPlaying && 'bg-muted/70',
|
isCurrentlyPlaying && 'bg-muted/70',
|
||||||
)}
|
)}
|
||||||
|
aria-label={
|
||||||
|
isGenerating
|
||||||
|
? `Generating speech for ${gen.profile_name}...`
|
||||||
|
: isFailed
|
||||||
|
? `Generation failed for ${gen.profile_name}`
|
||||||
|
: isCurrentlyPlaying
|
||||||
|
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
|
||||||
|
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Press Enter to play.`
|
||||||
|
}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
// Don't trigger play if clicking on textarea or if text is selected
|
if (!isPlayable) return;
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handlePlay(gen.id, gen.text, gen.profile_id);
|
handlePlay(gen.id, gen.text, gen.profile_id);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!isPlayable) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('textarea') || target.closest('button')) return;
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePlay(gen.id, gen.text, gen.profile_id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Waveform icon */}
|
{/* Status icon */}
|
||||||
<div className="flex items-center shrink-0">
|
<div className="flex items-center shrink-0 w-10 justify-center overflow-hidden">
|
||||||
<AudioWaveform className="h-5 w-5 text-muted-foreground" />
|
<div className="scale-50">
|
||||||
|
<Loader
|
||||||
|
type={isGenerating ? 'line-scale' : 'line-scale-pulse-out-rapid'}
|
||||||
|
active={isGenerating || isCurrentlyPlaying}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Left side - Meta information */}
|
{/* Left side - Meta information */}
|
||||||
@@ -267,11 +329,22 @@ export function HistoryTable() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">{gen.language}</span>
|
<span className="text-xs text-muted-foreground">{gen.language}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{formatDuration(gen.duration)}
|
{formatEngineName(gen.engine, gen.model_size)}
|
||||||
</span>
|
</span>
|
||||||
|
{isFailed ? (
|
||||||
|
<span className="text-xs text-destructive">Failed</span>
|
||||||
|
) : !isGenerating ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDuration(gen.duration ?? 0)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{formatDate(gen.created_at)}
|
{isGenerating ? (
|
||||||
|
<span className="text-accent">Generating...</span>
|
||||||
|
) : (
|
||||||
|
formatDate(gen.created_at)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -280,57 +353,71 @@ export function HistoryTable() {
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={gen.text}
|
value={gen.text}
|
||||||
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
||||||
|
readOnly
|
||||||
|
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Far right - Ellipsis actions */}
|
{/* Far right - Actions */}
|
||||||
<div
|
<div
|
||||||
className="w-10 shrink-0 flex justify-end"
|
className="w-10 shrink-0 flex justify-end items-center"
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DropdownMenu>
|
{isFailed ? (
|
||||||
<DropdownMenuTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
className="h-8 w-8"
|
||||||
className="h-8 w-8"
|
aria-label="Retry generation"
|
||||||
aria-label="Actions"
|
onClick={() => handleRetry(gen.id)}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
) : isPlayable ? (
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenu>
|
||||||
<DropdownMenuItem
|
<DropdownMenuTrigger asChild>
|
||||||
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<Play className="mr-2 h-4 w-4" />
|
size="icon"
|
||||||
Play
|
className="h-8 w-8"
|
||||||
</DropdownMenuItem>
|
aria-label="Actions"
|
||||||
<DropdownMenuItem
|
>
|
||||||
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
disabled={exportGenerationAudio.isPending}
|
</Button>
|
||||||
>
|
</DropdownMenuTrigger>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<DropdownMenuContent align="end">
|
||||||
Export Audio
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
||||||
<DropdownMenuItem
|
>
|
||||||
onClick={() => handleExportPackage(gen.id, gen.text)}
|
<Play className="mr-2 h-4 w-4" />
|
||||||
disabled={exportGeneration.isPending}
|
Play
|
||||||
>
|
</DropdownMenuItem>
|
||||||
<FileArchive className="mr-2 h-4 w-4" />
|
<DropdownMenuItem
|
||||||
Export Package
|
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||||
</DropdownMenuItem>
|
disabled={exportGenerationAudio.isPending}
|
||||||
<DropdownMenuItem
|
>
|
||||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
<Download className="mr-2 h-4 w-4" />
|
||||||
disabled={deleteGeneration.isPending}
|
Export Audio
|
||||||
className="text-destructive focus:text-destructive"
|
</DropdownMenuItem>
|
||||||
>
|
<DropdownMenuItem
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
onClick={() => handleExportPackage(gen.id, gen.text)}
|
||||||
Delete
|
disabled={exportGeneration.isPending}
|
||||||
</DropdownMenuItem>
|
>
|
||||||
</DropdownMenuContent>
|
<FileArchive className="mr-2 h-4 w-4" />
|
||||||
</DropdownMenu>
|
Export Package
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||||
|
disabled={deleteGeneration.isPending}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -358,7 +445,8 @@ export function HistoryTable() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Generation</DialogTitle>
|
<DialogTitle>Delete Generation</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete this generation from "{generationToDelete?.name}"? This action cannot be undone.
|
Are you sure you want to delete this generation from "{generationToDelete?.name}"?
|
||||||
|
This action cannot be undone.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
|
||||||
import { useImportProfile } from '@/lib/hooks/useProfiles';
|
import { useImportProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
@@ -77,9 +77,9 @@ export function MainEditor() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// Main view: Profiles top left, Generator bottom left, History right
|
// Main view: Profiles top left, Generator bottom left, History right
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative">
|
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
|
||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden relative">
|
<div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
|
||||||
{/* Scroll Mask - Always visible, behind content */}
|
{/* Scroll Mask - Always visible, behind content */}
|
||||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
|
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
|
||||||
|
|
||||||
@@ -110,10 +110,7 @@ export function MainEditor() {
|
|||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className={cn(
|
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
|
||||||
'flex-1 min-h-0 overflow-y-auto pt-14',
|
|
||||||
isPlayerVisible ? BOTTOM_SAFE_AREA_PADDING : 'pb-4',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="shrink-0 flex flex-col">
|
<div className="shrink-0 flex flex-col">
|
||||||
@@ -123,6 +120,9 @@ export function MainEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Divider - single column only */}
|
||||||
|
{/* <div className="border-t border-border -my-3 lg:hidden" /> */}
|
||||||
|
|
||||||
{/* Right Column - History */}
|
{/* Right Column - History */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||||
<HistoryTable />
|
<HistoryTable />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
|||||||
|
|
||||||
export function ModelsTab() {
|
export function ModelsTab() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 overflow-y-auto flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<ModelManagement />
|
<ModelManagement />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Loader2, XCircle } from 'lucide-react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -14,10 +17,10 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
const connectionSchema = z.object({
|
const connectionSchema = z.object({
|
||||||
serverUrl: z.string().url('Please enter a valid URL'),
|
serverUrl: z.string().url('Please enter a valid URL'),
|
||||||
@@ -31,7 +34,10 @@ export function ConnectionForm() {
|
|||||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
||||||
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
||||||
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
|
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
|
||||||
|
const mode = useServerStore((state) => state.mode);
|
||||||
|
const setMode = useServerStore((state) => state.setMode);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { data: health, isLoading, error: healthError } = useServerHealth();
|
||||||
|
|
||||||
const form = useForm<ConnectionFormValues>({
|
const form = useForm<ConnectionFormValues>({
|
||||||
resolver: zodResolver(connectionSchema),
|
resolver: zodResolver(connectionSchema),
|
||||||
@@ -49,7 +55,7 @@ export function ConnectionForm() {
|
|||||||
|
|
||||||
function onSubmit(data: ConnectionFormValues) {
|
function onSubmit(data: ConnectionFormValues) {
|
||||||
setServerUrl(data.serverUrl);
|
setServerUrl(data.serverUrl);
|
||||||
form.reset(data); // Reset form state after successful submission
|
form.reset(data);
|
||||||
toast({
|
toast({
|
||||||
title: 'Server URL updated',
|
title: 'Server URL updated',
|
||||||
description: `Connected to ${data.serverUrl}`,
|
description: `Connected to ${data.serverUrl}`,
|
||||||
@@ -57,7 +63,7 @@ export function ConnectionForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card role="region" aria-label="Server Connection" tabIndex={0}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Server Connection</CardTitle>
|
<CardTitle>Server Connection</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -83,10 +89,42 @@ export function ConnectionForm() {
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
|
{/* Connection status */}
|
||||||
|
<div className="mt-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-sm text-muted-foreground">Checking connection...</span>
|
||||||
|
</div>
|
||||||
|
) : healthError ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<XCircle className="h-4 w-4 text-destructive" />
|
||||||
|
<span className="text-sm text-destructive">
|
||||||
|
Connection failed: {healthError.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : health ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={health.model_loaded || health.model_downloaded ? 'default' : 'secondary'}
|
||||||
|
>
|
||||||
|
{health.model_loaded || health.model_downloaded ? 'Model Ready' : 'No Model'}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={health.gpu_available ? 'default' : 'secondary'}>
|
||||||
|
GPU: {health.gpu_available ? 'Available' : 'Not Available'}
|
||||||
|
</Badge>
|
||||||
|
{health.vram_used_mb && (
|
||||||
|
<Badge variant="outline">VRAM: {health.vram_used_mb.toFixed(0)} MB</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t">
|
<div className="mt-6 pt-6 border-t">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="keepServerRunning"
|
id="keepServerRunning"
|
||||||
|
className="mt-[6px]"
|
||||||
checked={keepServerRunningOnClose}
|
checked={keepServerRunningOnClose}
|
||||||
onCheckedChange={(checked: boolean) => {
|
onCheckedChange={(checked: boolean) => {
|
||||||
setKeepServerRunningOnClose(checked);
|
setKeepServerRunningOnClose(checked);
|
||||||
@@ -115,6 +153,39 @@ export function ConnectionForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{platform.metadata.isTauri && (
|
||||||
|
<div className="mt-6 pt-6 border-t">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id="allowNetworkAccess"
|
||||||
|
className="mt-[6px]"
|
||||||
|
checked={mode === 'remote'}
|
||||||
|
onCheckedChange={(checked: boolean) => {
|
||||||
|
setMode(checked ? 'remote' : 'local');
|
||||||
|
toast({
|
||||||
|
title: 'Setting updated',
|
||||||
|
description: checked
|
||||||
|
? 'Network access enabled. Restart the app to apply.'
|
||||||
|
: 'Network access disabled. Restart the app to apply.',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="allowNetworkAccess"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
Allow network access
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Makes the server accessible from other devices on your network. Restart the app
|
||||||
|
after changing this setting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
|
export function GenerationSettings() {
|
||||||
|
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
|
||||||
|
const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars);
|
||||||
|
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
||||||
|
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
|
||||||
|
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
|
||||||
|
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
|
||||||
|
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
|
||||||
|
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card role="region" aria-label="Generation Settings" tabIndex={0}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Generation Settings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Controls for long text generation. These settings apply to all engines.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label htmlFor="maxChunkChars" className="text-sm font-medium leading-none">
|
||||||
|
Auto-chunking limit
|
||||||
|
</label>
|
||||||
|
<span className="text-sm tabular-nums text-muted-foreground">
|
||||||
|
{maxChunkChars} chars
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
id="maxChunkChars"
|
||||||
|
value={[maxChunkChars]}
|
||||||
|
onValueChange={([value]) => setMaxChunkChars(value)}
|
||||||
|
min={100}
|
||||||
|
max={5000}
|
||||||
|
step={50}
|
||||||
|
aria-label="Auto-chunking character limit"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Long text is split into chunks at sentence boundaries before generating. Lower values
|
||||||
|
can improve quality for long outputs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label htmlFor="crossfadeMs" className="text-sm font-medium leading-none">
|
||||||
|
Chunk crossfade
|
||||||
|
</label>
|
||||||
|
<span className="text-sm tabular-nums text-muted-foreground">
|
||||||
|
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
id="crossfadeMs"
|
||||||
|
value={[crossfadeMs]}
|
||||||
|
onValueChange={([value]) => setCrossfadeMs(value)}
|
||||||
|
min={0}
|
||||||
|
max={200}
|
||||||
|
step={10}
|
||||||
|
aria-label="Chunk crossfade duration"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Blends audio between chunks to smooth transitions. Set to 0 for a hard cut.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id="normalizeAudio"
|
||||||
|
checked={normalizeAudio}
|
||||||
|
onCheckedChange={setNormalizeAudio}
|
||||||
|
className="mt-[6px]"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="normalizeAudio"
|
||||||
|
className="text-sm font-medium leading-none cursor-pointer"
|
||||||
|
>
|
||||||
|
Normalize audio
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Adjusts output volume to a consistent level across generations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id="autoplayOnGenerate"
|
||||||
|
checked={autoplayOnGenerate}
|
||||||
|
onCheckedChange={setAutoplayOnGenerate}
|
||||||
|
className="mt-[6px]"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="autoplayOnGenerate"
|
||||||
|
className="text-sm font-medium leading-none cursor-pointer"
|
||||||
|
>
|
||||||
|
Autoplay on generate
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Automatically play audio when a generation completes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AlertCircle, Download, Loader2, RotateCw, Trash2 } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { CudaDownloadProgress } from '@/lib/api/types';
|
||||||
|
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
|
type RestartPhase = 'idle' | 'stopping' | 'waiting' | 'ready';
|
||||||
|
|
||||||
|
export function GpuAcceleration() {
|
||||||
|
const platform = usePlatform();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
const { data: health } = useServerHealth();
|
||||||
|
|
||||||
|
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
|
||||||
|
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// Query CUDA backend status
|
||||||
|
const {
|
||||||
|
data: cudaStatus,
|
||||||
|
isLoading: cudaStatusLoading,
|
||||||
|
refetch: refetchCudaStatus,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['cuda-status', serverUrl],
|
||||||
|
queryFn: () => apiClient.getCudaStatus(),
|
||||||
|
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
|
||||||
|
retry: 1,
|
||||||
|
enabled: !!health, // Only fetch when backend is reachable
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derived state
|
||||||
|
const isCurrentlyCuda = health?.backend_variant === 'cuda';
|
||||||
|
const cudaAvailable = cudaStatus?.available ?? false;
|
||||||
|
const cudaDownloading = cudaStatus?.downloading ?? false;
|
||||||
|
|
||||||
|
// Clean up health poll on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (healthPollRef.current) {
|
||||||
|
clearInterval(healthPollRef.current);
|
||||||
|
healthPollRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// SSE progress tracking during download
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cudaDownloading || !serverUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data) as CudaDownloadProgress;
|
||||||
|
setDownloadProgress(data);
|
||||||
|
|
||||||
|
if (data.status === 'complete') {
|
||||||
|
eventSource.close();
|
||||||
|
setDownloadProgress(null);
|
||||||
|
refetchCudaStatus();
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
eventSource.close();
|
||||||
|
setError(data.error || 'Download failed');
|
||||||
|
setDownloadProgress(null);
|
||||||
|
refetchCudaStatus();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing CUDA progress event:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
|
||||||
|
|
||||||
|
// Start aggressive health polling during restart
|
||||||
|
const startHealthPolling = useCallback(() => {
|
||||||
|
if (healthPollRef.current) return;
|
||||||
|
|
||||||
|
healthPollRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const result = await apiClient.getHealth();
|
||||||
|
if (result.status === 'healthy') {
|
||||||
|
// Server is back up
|
||||||
|
if (healthPollRef.current) {
|
||||||
|
clearInterval(healthPollRef.current);
|
||||||
|
healthPollRef.current = null;
|
||||||
|
}
|
||||||
|
setRestartPhase('ready');
|
||||||
|
// Invalidate all queries to refresh UI
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
// Reset after a moment
|
||||||
|
setTimeout(() => setRestartPhase('idle'), 2000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Server still down, keep polling
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiClient.downloadCudaBackend();
|
||||||
|
refetchCudaStatus();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Failed to start download';
|
||||||
|
if (msg.includes('already downloaded')) {
|
||||||
|
refetchCudaStatus();
|
||||||
|
} else {
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestart = async () => {
|
||||||
|
setError(null);
|
||||||
|
setRestartPhase('stopping');
|
||||||
|
|
||||||
|
try {
|
||||||
|
setRestartPhase('waiting');
|
||||||
|
startHealthPolling();
|
||||||
|
await platform.lifecycle.restartServer();
|
||||||
|
// Invoke resolved — server is likely ready. Stop polling and refresh.
|
||||||
|
if (healthPollRef.current) {
|
||||||
|
clearInterval(healthPollRef.current);
|
||||||
|
healthPollRef.current = null;
|
||||||
|
}
|
||||||
|
setRestartPhase('ready');
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
setTimeout(() => setRestartPhase('idle'), 2000);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setRestartPhase('idle');
|
||||||
|
if (healthPollRef.current) {
|
||||||
|
clearInterval(healthPollRef.current);
|
||||||
|
healthPollRef.current = null;
|
||||||
|
}
|
||||||
|
setError(e instanceof Error ? e.message : 'Restart failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwitchToCpu = async () => {
|
||||||
|
// To switch to CPU: delete the CUDA binary, then restart.
|
||||||
|
// start_server always prefers CUDA if present, so we must remove it first.
|
||||||
|
setError(null);
|
||||||
|
setRestartPhase('stopping');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.deleteCudaBackend();
|
||||||
|
setRestartPhase('waiting');
|
||||||
|
startHealthPolling();
|
||||||
|
await platform.lifecycle.restartServer();
|
||||||
|
// Invoke resolved — server is likely ready
|
||||||
|
if (healthPollRef.current) {
|
||||||
|
clearInterval(healthPollRef.current);
|
||||||
|
healthPollRef.current = null;
|
||||||
|
}
|
||||||
|
setRestartPhase('ready');
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
setTimeout(() => setRestartPhase('idle'), 2000);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setRestartPhase('idle');
|
||||||
|
if (healthPollRef.current) {
|
||||||
|
clearInterval(healthPollRef.current);
|
||||||
|
healthPollRef.current = null;
|
||||||
|
}
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
|
||||||
|
refetchCudaStatus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiClient.deleteCudaBackend();
|
||||||
|
refetchCudaStatus();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to delete CUDA backend');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't render until health data is available
|
||||||
|
if (!health) return null;
|
||||||
|
|
||||||
|
// If the system already has native GPU (MPS, etc.), only show info - no CUDA needed
|
||||||
|
const hasNativeGpu =
|
||||||
|
health.gpu_available &&
|
||||||
|
!isCurrentlyCuda &&
|
||||||
|
health.gpu_type &&
|
||||||
|
!health.gpu_type.includes('CUDA');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>GPU Acceleration</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Current status */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium">Backend</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{isCurrentlyCuda
|
||||||
|
? 'CUDA (GPU accelerated)'
|
||||||
|
: hasNativeGpu
|
||||||
|
? `${health.backend_type === 'mlx' ? 'MLX' : 'PyTorch'} (GPU accelerated)`
|
||||||
|
: 'CPU'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GPU info from health */}
|
||||||
|
{health.gpu_type && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium">GPU</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{health.gpu_type}</div>
|
||||||
|
{health.vram_used_mb != null && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
VRAM: {health.vram_used_mb.toFixed(0)} MB used
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Native GPU detected - no CUDA download needed */}
|
||||||
|
|
||||||
|
{/* CUDA download section - only show when native GPU is NOT detected (i.e., Windows/Linux NVIDIA users) */}
|
||||||
|
{!hasNativeGpu && (
|
||||||
|
<>
|
||||||
|
{/* Download progress */}
|
||||||
|
{cudaDownloading && downloadProgress && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span>{downloadProgress.filename || 'Downloading CUDA backend...'}</span>
|
||||||
|
</div>
|
||||||
|
{downloadProgress.total > 0 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{downloadProgress.progress.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{downloadProgress.total > 0 && (
|
||||||
|
<>
|
||||||
|
<Progress value={downloadProgress.progress} className="h-2" />
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(downloadProgress.current)} /{' '}
|
||||||
|
{formatBytes(downloadProgress.total)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Restart in progress */}
|
||||||
|
{restartPhase !== 'idle' && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-primary/5 border">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-sm">
|
||||||
|
{restartPhase === 'stopping' && 'Stopping server...'}
|
||||||
|
{restartPhase === 'waiting' && 'Restarting server...'}
|
||||||
|
{restartPhase === 'ready' && 'Server restarted successfully!'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{restartPhase === 'idle' && !cudaDownloading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Not downloaded yet - show download button */}
|
||||||
|
{!cudaAvailable && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Download the CUDA backend (~2.4 GB) for NVIDIA GPU acceleration. Requires an
|
||||||
|
NVIDIA GPU with CUDA support.
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleDownload} className="w-full" size="sm">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download CUDA Backend
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Downloaded but not active - show switch button */}
|
||||||
|
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
CUDA backend is downloaded and ready. Restart the server to enable GPU
|
||||||
|
acceleration.
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleRestart} className="w-full" size="sm">
|
||||||
|
<RotateCw className="h-4 w-4 mr-2" />
|
||||||
|
Switch to CUDA Backend
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Currently active - show switch back to CPU */}
|
||||||
|
{isCurrentlyCuda && platform.metadata.isTauri && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Running with CUDA GPU acceleration. Switch back to CPU if needed (you can
|
||||||
|
re-download later).
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleSwitchToCpu}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<RotateCw className="h-4 w-4 mr-2" />
|
||||||
|
Switch to CPU Backend
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete option when downloaded (and not active) */}
|
||||||
|
{cudaAvailable && !isCurrentlyCuda && (
|
||||||
|
<Button
|
||||||
|
onClick={handleDelete}
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full text-muted-foreground hover:text-destructive"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Remove CUDA Backend
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,14 +8,23 @@ import { useServerStore } from '@/stores/serverStore';
|
|||||||
interface ModelProgressProps {
|
interface ModelProgressProps {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
/** Only connect to SSE when actively downloading - prevents connection exhaustion */
|
||||||
|
isDownloading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
export function ModelProgress({ modelName, displayName, isDownloading = false }: ModelProgressProps) {
|
||||||
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverUrl) return;
|
// IMPORTANT: Only connect to SSE when this specific model is downloading
|
||||||
|
// Opening SSE connections for all models exhausts HTTP/1.1 connection limits (6 per origin)
|
||||||
|
// which causes other fetches (like the download trigger) to be queued/blocked
|
||||||
|
if (!serverUrl || !isDownloading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ModelProgress] Connecting SSE for ${modelName}`);
|
||||||
|
|
||||||
// Subscribe to progress updates via Server-Sent Events
|
// Subscribe to progress updates via Server-Sent Events
|
||||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||||
@@ -27,6 +36,7 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
|
|
||||||
// Close connection if complete or error
|
// Close connection if complete or error
|
||||||
if (data.status === 'complete' || data.status === 'error') {
|
if (data.status === 'complete' || data.status === 'error') {
|
||||||
|
console.log(`[ModelProgress] Download ${data.status} for ${modelName}, closing SSE`);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -35,14 +45,15 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error('SSE error:', error);
|
console.error(`[ModelProgress] SSE error for ${modelName}:`, error);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log(`[ModelProgress] Cleanup - closing SSE for ${modelName}`);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
};
|
};
|
||||||
}, [serverUrl, modelName]);
|
}, [serverUrl, modelName, isDownloading]);
|
||||||
|
|
||||||
// Don't render if no progress or if complete/error and some time has passed
|
// Don't render if no progress or if complete/error and some time has passed
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import { ModelProgress } from './ModelProgress';
|
|
||||||
|
|
||||||
export function ServerStatus() {
|
export function ServerStatus() {
|
||||||
const { data: health, isLoading, error } = useServerHealth();
|
const { data: health, isLoading, error } = useServerHealth();
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card role="region" aria-label="Server Status" tabIndex={0}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Server Status</CardTitle>
|
<CardTitle>Server Status</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -20,16 +19,6 @@ export function ServerStatus() {
|
|||||||
<div className="font-mono text-sm">{serverUrl}</div>
|
<div className="font-mono text-sm">{serverUrl}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model download progress */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<ModelProgress modelName="qwen-tts-1.7B" displayName="Qwen TTS 1.7B" />
|
|
||||||
<ModelProgress modelName="qwen-tts-0.6B" displayName="Qwen TTS 0.6B" />
|
|
||||||
<ModelProgress modelName="whisper-base" displayName="Whisper Base" />
|
|
||||||
<ModelProgress modelName="whisper-small" displayName="Whisper Small" />
|
|
||||||
<ModelProgress modelName="whisper-medium" displayName="Whisper Medium" />
|
|
||||||
<ModelProgress modelName="whisper-large" displayName="Whisper Large" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@@ -13,13 +13,18 @@ export function UpdateStatus() {
|
|||||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
platform.metadata.getVersion()
|
platform.metadata
|
||||||
|
.getVersion()
|
||||||
.then(setCurrentVersion)
|
.then(setCurrentVersion)
|
||||||
.catch(() => setCurrentVersion('0.1.0'));
|
.catch(() => setCurrentVersion('Unknown'));
|
||||||
}, [platform]);
|
}, [platform]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card
|
||||||
|
role="region"
|
||||||
|
aria-label="App Updates"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>App Updates</CardTitle>
|
<CardTitle>App Updates</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||||
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
import { GenerationSettings } from '@/components/ServerSettings/GenerationSettings';
|
||||||
|
import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration';
|
||||||
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||||
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
export function ServerTab() {
|
export function ServerTab() {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
|
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 overflow-y-auto flex flex-col">
|
<div
|
||||||
|
className={cn('overflow-y-auto flex flex-col', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}
|
||||||
|
>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<ConnectionForm />
|
<ConnectionForm />
|
||||||
<ServerStatus />
|
<GenerationSettings />
|
||||||
|
{platform.metadata.isTauri && <GpuAcceleration />}
|
||||||
|
{platform.metadata.isTauri && <UpdateStatus />}
|
||||||
</div>
|
</div>
|
||||||
{platform.metadata.isTauri && <UpdateStatus />}
|
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
Created by{' '}
|
Created by{' '}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Link, useMatchRoute } from '@tanstack/react-router';
|
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||||
import { Box, BookOpen, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
|
import { BookOpen, Box, Mic, Server, Speaker, Volume2 } from 'lucide-react';
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { version } from '../../package.json';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isMacOS?: boolean;
|
isMacOS?: boolean;
|
||||||
@@ -19,10 +19,8 @@ const tabs = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ isMacOS }: SidebarProps) {
|
export function Sidebar({ isMacOS }: SidebarProps) {
|
||||||
const isGenerating = useGenerationStore((state) => state.isGenerating);
|
|
||||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
|
||||||
const isPlayerVisible = !!audioUrl;
|
|
||||||
const matchRoute = useMatchRoute();
|
const matchRoute = useMatchRoute();
|
||||||
|
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -42,9 +40,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
|||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
// For index route, use exact match; for others, use default matching
|
// For index route, use exact match; for others, use default matching
|
||||||
const isActive =
|
const isActive =
|
||||||
tab.path === '/'
|
tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path });
|
||||||
? matchRoute({ to: '/', exact: true })
|
|
||||||
: matchRoute({ to: tab.path });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -64,20 +60,13 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spacer to push loader to bottom */}
|
{/* Version */}
|
||||||
<div className="flex-1" />
|
<div
|
||||||
|
className="mt-auto text-[10px] text-muted-foreground/50 transition-all duration-300"
|
||||||
{/* Generation Loader */}
|
style={{ paddingBottom: isPlayerOpen ? '7rem' : undefined }}
|
||||||
{isGenerating && (
|
>
|
||||||
<div
|
v{version}
|
||||||
className={cn(
|
</div>
|
||||||
'w-full flex items-center justify-center transition-all duration-200',
|
|
||||||
isPlayerVisible ? 'mb-[120px]' : 'mb-0',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Loader2 className="h-6 w-6 text-accent animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { StoryContent } from './StoryContent';
|
import { StoryContent } from './StoryContent';
|
||||||
import { StoryList } from './StoryList';
|
import { StoryList } from './StoryList';
|
||||||
|
|
||||||
export function StoriesTab() {
|
export function StoriesTab() {
|
||||||
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
@@ -18,7 +21,7 @@ export function StoriesTab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
|
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
|
||||||
<FloatingGenerateBox showVoiceSelector />
|
<FloatingGenerateBox showVoiceSelector isPlayerOpen={!!audioUrl} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import {
|
|||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Download, Plus } from 'lucide-react';
|
import { Download, Plus } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import Loader from 'react-loaders';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
@@ -28,6 +31,7 @@ import {
|
|||||||
useStory,
|
useStory,
|
||||||
} from '@/lib/hooks/useStories';
|
} from '@/lib/hooks/useStories';
|
||||||
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { SortableStoryChatItem } from './StoryChatItem';
|
import { SortableStoryChatItem } from './StoryChatItem';
|
||||||
|
|
||||||
@@ -40,6 +44,7 @@ export function StoryContent() {
|
|||||||
const addStoryItem = useAddStoryItem();
|
const addStoryItem = useAddStoryItem();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
||||||
|
|
||||||
// Add generation popover state
|
// Add generation popover state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -53,9 +58,9 @@ export function StoryContent() {
|
|||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return historyData.items.filter(
|
return historyData.items.filter(
|
||||||
(gen) =>
|
(gen) =>
|
||||||
|
gen.status === 'completed' &&
|
||||||
!storyGenerationIds.has(gen.id) &&
|
!storyGenerationIds.has(gen.id) &&
|
||||||
(gen.text.toLowerCase().includes(query) ||
|
(gen.text.toLowerCase().includes(query) || gen.profile_name.toLowerCase().includes(query)),
|
||||||
gen.profile_name.toLowerCase().includes(query)),
|
|
||||||
);
|
);
|
||||||
}, [historyData, story, searchQuery]);
|
}, [historyData, story, searchQuery]);
|
||||||
|
|
||||||
@@ -267,7 +272,31 @@ export function StoryContent() {
|
|||||||
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-center">
|
||||||
|
<AnimatePresence>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, width: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, width: 'auto' }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, width: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-2 h-8 pl-1.5 pr-3 rounded-full bg-card border border-border hover:bg-muted/50 transition-all duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="shrink-0 w-10 h-5 overflow-hidden flex items-center justify-center">
|
||||||
|
<div className="scale-[0.45]">
|
||||||
|
<Loader type="line-scale" active />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
@@ -287,9 +316,7 @@ export function StoryContent() {
|
|||||||
<div className="max-h-60 overflow-y-auto">
|
<div className="max-h-60 overflow-y-auto">
|
||||||
{availableGenerations.length === 0 ? (
|
{availableGenerations.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
{searchQuery
|
{searchQuery ? 'No matching generations found' : 'No available generations'}
|
||||||
? 'No matching generations found'
|
|
||||||
: 'No available generations'}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
availableGenerations.map((gen) => (
|
availableGenerations.map((gen) => (
|
||||||
|
|||||||
@@ -194,17 +194,29 @@ export function StoryList() {
|
|||||||
storyList.map((story) => (
|
storyList.map((story) => (
|
||||||
<div
|
<div
|
||||||
key={story.id}
|
key={story.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-24 p-4 border rounded-2xl transition-colors group flex items-center',
|
'h-24 p-4 border rounded-2xl transition-colors group flex items-center cursor-pointer',
|
||||||
selectedStoryId === story.id && 'bg-muted border-primary',
|
selectedStoryId === story.id && 'bg-muted border-primary',
|
||||||
)}
|
)}
|
||||||
|
aria-label={
|
||||||
|
selectedStoryId === story.id
|
||||||
|
? `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Selected. Press Enter to select.`
|
||||||
|
: `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Press Enter to select.`
|
||||||
|
}
|
||||||
|
aria-pressed={selectedStoryId === story.id}
|
||||||
|
onClick={() => setSelectedStoryId(story.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedStoryId(story.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
||||||
<button
|
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||||
type="button"
|
|
||||||
className="flex-1 min-w-0 text-left cursor-pointer overflow-hidden"
|
|
||||||
onClick={() => setSelectedStoryId(story.id)}
|
|
||||||
>
|
|
||||||
<h3 className="font-medium truncate">{story.name}</h3>
|
<h3 className="font-medium truncate">{story.name}</h3>
|
||||||
{story.description && (
|
{story.description && (
|
||||||
<p className="text-sm text-muted-foreground mt-1 truncate">
|
<p className="text-sm text-muted-foreground mt-1 truncate">
|
||||||
@@ -218,7 +230,7 @@ export function StoryList() {
|
|||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{formatDate(story.updated_at)}</span>
|
<span>{formatDate(story.updated_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -226,6 +238,7 @@ export function StoryList() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Actions for ${story.name}`}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -736,6 +736,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handlePlayPause}
|
onClick={handlePlayPause}
|
||||||
title="Play/Pause (Space)"
|
title="Play/Pause (Space)"
|
||||||
|
aria-label={isCurrentlyPlaying ? 'Pause' : 'Play'}
|
||||||
>
|
>
|
||||||
{isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
{isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -745,6 +746,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
disabled={!isCurrentlyPlaying}
|
disabled={!isCurrentlyPlaying}
|
||||||
|
aria-label="Stop"
|
||||||
>
|
>
|
||||||
<Square className="h-3 w-3" />
|
<Square className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -762,6 +764,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleSplit}
|
onClick={handleSplit}
|
||||||
title="Split at playhead (S)"
|
title="Split at playhead (S)"
|
||||||
|
aria-label="Split at playhead"
|
||||||
>
|
>
|
||||||
<Scissors className="h-4 w-4" />
|
<Scissors className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -771,6 +774,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleDuplicate}
|
onClick={handleDuplicate}
|
||||||
title="Duplicate (Cmd/Ctrl+D)"
|
title="Duplicate (Cmd/Ctrl+D)"
|
||||||
|
aria-label="Duplicate clip"
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -780,6 +784,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
title="Delete (Delete/Backspace)"
|
title="Delete (Delete/Backspace)"
|
||||||
|
aria-label="Delete clip"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -789,10 +794,22 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
{/* Zoom controls - right side */}
|
{/* Zoom controls - right side */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">Zoom:</span>
|
<span className="text-xs text-muted-foreground">Zoom:</span>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
<Minus className="h-3 w-3" />
|
<Minus className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function AudioSampleRecording({
|
|||||||
// Request microphone access when component mounts
|
// Request microphone access when component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showWaveform) return;
|
if (!showWaveform) return;
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
|
||||||
|
|
||||||
let stream: MediaStream | null = null;
|
let stream: MediaStream | null = null;
|
||||||
|
|
||||||
@@ -139,7 +140,13 @@ export function AudioSampleRecording({
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPlayPause}
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -77,7 +77,13 @@ export function AudioSampleSystem({
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPlayPause}
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export function AudioSampleUpload({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onPlayPause}
|
onClick={onPlayPause}
|
||||||
disabled={isValidating}
|
disabled={isValidating}
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -61,14 +61,32 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
exportProfile.mutate(profile.id);
|
exportProfile.mutate(profile.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('button')) return;
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectLabel = isSelected
|
||||||
|
? `${profile.name}, ${profile.language}. Selected as voice for generation.`
|
||||||
|
: `${profile.name}, ${profile.language}. Select as voice for generation.`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer hover:shadow-md transition-all flex flex-col',
|
'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]',
|
||||||
isSelected && 'ring-2 ring-primary shadow-md',
|
isSelected && 'ring-2 ring-primary shadow-md',
|
||||||
)}
|
)}
|
||||||
onClick={handleSelect}
|
onClick={handleSelect}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label={selectLabel}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<CardHeader className="p-3 pb-2">
|
<CardHeader className="p-3 pb-2">
|
||||||
<CardTitle className="flex items-center gap-1.5 text-base font-medium">
|
<CardTitle className="flex items-center gap-1.5 text-base font-medium">
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
} from '@/lib/hooks/useProfiles';
|
} from '@/lib/hooks/useProfiles';
|
||||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||||
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
import { convertToWav, formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
|
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
|
||||||
@@ -505,10 +505,23 @@ export function ProfileForm() {
|
|||||||
language: data.language,
|
language: data.language,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Convert non-WAV uploads to WAV so the backend can always use soundfile.
|
||||||
|
// Recorded audio is already WAV (from useAudioRecording's convertToWav call).
|
||||||
|
let fileToUpload: File = sampleFile;
|
||||||
|
if (!sampleFile.type.includes('wav') && !sampleFile.name.toLowerCase().endsWith('.wav')) {
|
||||||
|
try {
|
||||||
|
const wavBlob = await convertToWav(sampleFile);
|
||||||
|
const wavName = sampleFile.name.replace(/\.[^.]+$/, '.wav');
|
||||||
|
fileToUpload = new File([wavBlob], wavName, { type: 'audio/wav' });
|
||||||
|
} catch {
|
||||||
|
// If browser can't decode the format, send the original and let the backend try.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addSample.mutateAsync({
|
await addSample.mutateAsync({
|
||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
file: sampleFile,
|
file: fileToUpload,
|
||||||
referenceText: referenceText,
|
referenceText: referenceText,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ export function ProfileList() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1 pb-[150px]">
|
<div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]">
|
||||||
{allProfiles.map((profile) => (
|
{allProfiles.map((profile) => (
|
||||||
<ProfileCard key={profile.id} profile={profile} />
|
<div key={profile.id} className="shrink-0 w-[200px] lg:w-auto lg:shrink">
|
||||||
|
<ProfileCard profile={profile} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
|||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
onClick={handlePlayPause}
|
onClick={handlePlayPause}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
aria-label={isPlaying ? 'Pause sample' : 'Play sample'}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -113,6 +114,8 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
|||||||
max={100}
|
max={100}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
aria-label="Sample playback position"
|
||||||
|
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
||||||
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
||||||
@@ -128,6 +131,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
|||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
title="Stop"
|
title="Stop"
|
||||||
|
aria-label="Stop playback"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ export function VoicesTab() {
|
|||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (profileId: string) => {
|
const handleProfileDelete = async (profileId: string) => {
|
||||||
if (confirm('Are you sure you want to delete this profile?')) {
|
if (await confirm('Are you sure you want to delete this profile?')) {
|
||||||
deleteProfile.mutate(profileId);
|
deleteProfile.mutate(profileId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -147,7 +147,7 @@ export function VoicesTab() {
|
|||||||
channels={channels || []}
|
channels={channels || []}
|
||||||
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
|
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
|
||||||
onEdit={() => handleEdit(profile.id)}
|
onEdit={() => handleEdit(profile.id)}
|
||||||
onDelete={() => handleDelete(profile.id)}
|
onDelete={() => handleProfileDelete(profile.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -179,25 +179,36 @@ function VoiceRow({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}: VoiceRowProps) {
|
}: VoiceRowProps) {
|
||||||
const { data: samples } = useProfileSamples(profile.id);
|
const { data: samples } = useProfileSamples(profile.id);
|
||||||
|
const sampleCount = samples?.length || 0;
|
||||||
|
|
||||||
|
const rowLabel = `${profile.name}, ${profile.language}, ${generationCount} generations, ${sampleCount} samples. Press Enter to edit.`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow className="cursor-pointer" onClick={onEdit}>
|
<TableRow className="cursor-pointer" onClick={onEdit}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full min-w-0 items-center gap-2 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
|
||||||
|
aria-label={rowLabel}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||||
<Mic className="h-4 w-4 text-muted-foreground" />
|
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="font-medium">{profile.name}</div>
|
<div className="font-medium truncate">{profile.name}</div>
|
||||||
{profile.description && (
|
{profile.description && (
|
||||||
<div className="text-sm text-muted-foreground">{profile.description}</div>
|
<div className="text-sm text-muted-foreground truncate">{profile.description}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>{profile.language}</TableCell>
|
<TableCell onClick={(e) => e.stopPropagation()}>{profile.language}</TableCell>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>{generationCount}</TableCell>
|
<TableCell onClick={(e) => e.stopPropagation()}>{generationCount}</TableCell>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>{samples?.length || 0}</TableCell>
|
<TableCell onClick={(e) => e.stopPropagation()}>{sampleCount}</TableCell>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
options={channels.map((ch) => ({
|
options={channels.map((ch) => ({
|
||||||
@@ -213,7 +224,7 @@ function VoiceRow({
|
|||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon" aria-label={`Actions for ${profile.name}`}>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
export interface CheckboxProps {
|
export interface CheckboxProps {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import type { UpdateStatus } from '@/platform/types';
|
import type { UpdateStatus } from '@/platform/types';
|
||||||
|
|
||||||
@@ -7,9 +7,8 @@ export type { UpdateStatus };
|
|||||||
|
|
||||||
export function useAutoUpdater(checkOnMount = false) {
|
export function useAutoUpdater(checkOnMount = false) {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const [status, setStatus] = useState<UpdateStatus>(
|
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||||
platform.updater.getStatus(),
|
const hasCheckedRef = useRef(false);
|
||||||
);
|
|
||||||
|
|
||||||
// Subscribe to updater status changes
|
// Subscribe to updater status changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -17,25 +16,32 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
});
|
});
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [platform]);
|
// Empty dependency array - platform is stable from context
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.subscribe]);
|
||||||
|
|
||||||
const checkForUpdates = useCallback(async () => {
|
const checkForUpdates = useCallback(async () => {
|
||||||
await platform.updater.checkForUpdates();
|
await platform.updater.checkForUpdates();
|
||||||
}, [platform]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.checkForUpdates]);
|
||||||
|
|
||||||
const downloadAndInstall = useCallback(async () => {
|
const downloadAndInstall = useCallback(async () => {
|
||||||
await platform.updater.downloadAndInstall();
|
await platform.updater.downloadAndInstall();
|
||||||
}, [platform]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.downloadAndInstall]);
|
||||||
|
|
||||||
const restartAndInstall = useCallback(async () => {
|
const restartAndInstall = useCallback(async () => {
|
||||||
await platform.updater.restartAndInstall();
|
await platform.updater.restartAndInstall();
|
||||||
}, [platform]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.restartAndInstall]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (checkOnMount && platform.metadata.isTauri) {
|
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||||
|
hasCheckedRef.current = true;
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
}
|
}
|
||||||
}, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { Download, RefreshCw } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { ToastAction } from '@/components/ui/toast';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import type { UpdateStatus } from '@/platform/types';
|
||||||
|
|
||||||
|
// Re-export UpdateStatus for backwards compatibility
|
||||||
|
export type { UpdateStatus };
|
||||||
|
|
||||||
|
interface UseAutoUpdaterOptions {
|
||||||
|
checkOnMount?: boolean;
|
||||||
|
showToast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) {
|
||||||
|
// Support both old boolean API and new options object
|
||||||
|
const { checkOnMount, showToast } =
|
||||||
|
typeof options === 'boolean'
|
||||||
|
? { checkOnMount: options, showToast: false }
|
||||||
|
: { checkOnMount: options.checkOnMount ?? false, showToast: options.showToast ?? false };
|
||||||
|
|
||||||
|
const platform = usePlatform();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||||
|
const hasCheckedRef = useRef(false);
|
||||||
|
const toastIdRef = useRef<string | null>(null);
|
||||||
|
const toastUpdateRef = useRef<
|
||||||
|
| ((props: {
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
duration?: number;
|
||||||
|
variant?: 'default' | 'destructive';
|
||||||
|
open?: boolean;
|
||||||
|
action?: React.ReactElement<typeof ToastAction>;
|
||||||
|
}) => void)
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
// Subscribe to updater status changes
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = platform.updater.subscribe((newStatus) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
// Empty dependency array - platform is stable from context
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.subscribe]);
|
||||||
|
|
||||||
|
const checkForUpdates = useCallback(async () => {
|
||||||
|
await platform.updater.checkForUpdates();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.checkForUpdates]);
|
||||||
|
|
||||||
|
const downloadAndInstall = useCallback(async () => {
|
||||||
|
await platform.updater.downloadAndInstall();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.downloadAndInstall]);
|
||||||
|
|
||||||
|
const restartAndInstall = useCallback(async () => {
|
||||||
|
await platform.updater.restartAndInstall();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.restartAndInstall]);
|
||||||
|
|
||||||
|
// Check for updates on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||||
|
hasCheckedRef.current = true;
|
||||||
|
checkForUpdates().catch((error) => {
|
||||||
|
console.error('Auto update check failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Empty dependency array - only run once on mount
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||||
|
|
||||||
|
// Show toast when update is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!showToast ||
|
||||||
|
!status.available ||
|
||||||
|
status.downloading ||
|
||||||
|
status.readyToInstall ||
|
||||||
|
toastIdRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateNow = async () => {
|
||||||
|
await downloadAndInstall();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toastResult = toast({
|
||||||
|
title: 'Update Available',
|
||||||
|
description: `Version ${status.version} is ready to download.`,
|
||||||
|
duration: Infinity,
|
||||||
|
action: (
|
||||||
|
<ToastAction altText="Update now" onClick={handleUpdateNow}>
|
||||||
|
Update Now
|
||||||
|
</ToastAction>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
toastIdRef.current = toastResult.id;
|
||||||
|
// Type assertion needed because update function has broader type than our ref
|
||||||
|
toastUpdateRef.current = toastResult.update as typeof toastUpdateRef.current;
|
||||||
|
}, [
|
||||||
|
showToast,
|
||||||
|
status.available,
|
||||||
|
status.downloading,
|
||||||
|
status.readyToInstall,
|
||||||
|
status.version,
|
||||||
|
downloadAndInstall,
|
||||||
|
toast,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update toast when downloading
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showToast || !status.downloading || !toastIdRef.current || !toastUpdateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPercent = status.downloadProgress || 0;
|
||||||
|
const progressText =
|
||||||
|
status.downloadedBytes !== undefined &&
|
||||||
|
status.totalBytes !== undefined &&
|
||||||
|
status.totalBytes > 0
|
||||||
|
? `${(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB / ${(status.totalBytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Download className="h-4 w-4 animate-pulse" />
|
||||||
|
<span>Downloading Update</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
description: (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm">Version {status.version}</div>
|
||||||
|
{progressPercent > 0 && (
|
||||||
|
<>
|
||||||
|
<Progress value={progressPercent} className="h-2" />
|
||||||
|
{progressText && <div className="text-xs text-muted-foreground">{progressText}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
duration: Infinity,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
showToast,
|
||||||
|
status.downloading,
|
||||||
|
status.downloadProgress,
|
||||||
|
status.downloadedBytes,
|
||||||
|
status.totalBytes,
|
||||||
|
status.version,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update toast when ready to install
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showToast || !status.readyToInstall || !toastIdRef.current || !toastUpdateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestartNow = async () => {
|
||||||
|
await restartAndInstall();
|
||||||
|
};
|
||||||
|
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: 'Update Ready',
|
||||||
|
description: `Version ${status.version} has been downloaded and is ready to install.`,
|
||||||
|
duration: Infinity,
|
||||||
|
action: (
|
||||||
|
<ToastAction altText="Restart now" onClick={handleRestartNow}>
|
||||||
|
<RefreshCw className="h-3 w-3 mr-1" />
|
||||||
|
Restart Now
|
||||||
|
</ToastAction>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [showToast, status.readyToInstall, status.version, restartAndInstall]);
|
||||||
|
|
||||||
|
// Handle errors in toast
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showToast || !status.error || !toastIdRef.current || !toastUpdateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: 'Update Failed',
|
||||||
|
description: status.error,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toastIdRef.current = null;
|
||||||
|
toastUpdateRef.current = null;
|
||||||
|
}, 5000);
|
||||||
|
}, [showToast, status.error]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
checkForUpdates,
|
||||||
|
downloadAndInstall,
|
||||||
|
restartAndInstall,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss" source(".");
|
@import "tailwindcss" source(".");
|
||||||
|
@import "loaders.css/loaders.min.css";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
@@ -155,3 +156,18 @@
|
|||||||
animation: fadeIn 0.5s ease-out 0.15s forwards;
|
animation: fadeIn 0.5s ease-out 0.15s forwards;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* react-loaders */
|
||||||
|
.line-scale-pulse-out-rapid > div,
|
||||||
|
.line-scale > div {
|
||||||
|
background-color: hsl(var(--accent)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-hidden {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-hidden > div > div {
|
||||||
|
animation-play-state: paused !important;
|
||||||
|
background-color: hsl(var(--muted-foreground)) !important;
|
||||||
|
}
|
||||||
|
|||||||
+109
-31
@@ -1,29 +1,30 @@
|
|||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
import type { LanguageCode } from '@/lib/constants/languages';
|
import type { LanguageCode } from '@/lib/constants/languages';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import type {
|
import type {
|
||||||
VoiceProfileCreate,
|
ActiveTasksResponse,
|
||||||
VoiceProfileResponse,
|
CudaStatus,
|
||||||
ProfileSampleResponse,
|
|
||||||
GenerationRequest,
|
GenerationRequest,
|
||||||
GenerationResponse,
|
GenerationResponse,
|
||||||
HistoryQuery,
|
|
||||||
HistoryListResponse,
|
|
||||||
HistoryResponse,
|
|
||||||
TranscriptionResponse,
|
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
ModelStatusListResponse,
|
HistoryListResponse,
|
||||||
|
HistoryQuery,
|
||||||
|
HistoryResponse,
|
||||||
ModelDownloadRequest,
|
ModelDownloadRequest,
|
||||||
ActiveTasksResponse,
|
ModelStatusListResponse,
|
||||||
|
ProfileSampleResponse,
|
||||||
StoryCreate,
|
StoryCreate,
|
||||||
StoryResponse,
|
|
||||||
StoryDetailResponse,
|
StoryDetailResponse,
|
||||||
|
StoryItemBatchUpdate,
|
||||||
StoryItemCreate,
|
StoryItemCreate,
|
||||||
StoryItemDetail,
|
StoryItemDetail,
|
||||||
StoryItemBatchUpdate,
|
|
||||||
StoryItemReorder,
|
|
||||||
StoryItemMove,
|
StoryItemMove,
|
||||||
StoryItemTrim,
|
StoryItemReorder,
|
||||||
StoryItemSplit,
|
StoryItemSplit,
|
||||||
|
StoryItemTrim,
|
||||||
|
StoryResponse,
|
||||||
|
TranscriptionResponse,
|
||||||
|
VoiceProfileCreate,
|
||||||
|
VoiceProfileResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
@@ -199,6 +200,12 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async retryGeneration(generationId: string): Promise<GenerationResponse> {
|
||||||
|
return this.request<GenerationResponse>(`/generate/${generationId}/retry`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// History
|
// History
|
||||||
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -251,7 +258,13 @@ class ApiClient {
|
|||||||
return response.blob();
|
return response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
async importGeneration(file: File): Promise<{ id: string; profile_id: string; profile_name: string; text: string; message: string }> {
|
async importGeneration(file: File): Promise<{
|
||||||
|
id: string;
|
||||||
|
profile_id: string;
|
||||||
|
profile_name: string;
|
||||||
|
text: string;
|
||||||
|
message: string;
|
||||||
|
}> {
|
||||||
const url = `${this.getBaseUrl()}/history/import`;
|
const url = `${this.getBaseUrl()}/history/import`;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
@@ -271,6 +284,11 @@ class ApiClient {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generation status SSE
|
||||||
|
getGenerationStatusUrl(generationId: string): string {
|
||||||
|
return `${this.getBaseUrl()}/generate/${generationId}/status`;
|
||||||
|
}
|
||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
getAudioUrl(audioId: string): string {
|
getAudioUrl(audioId: string): string {
|
||||||
return `${this.getBaseUrl()}/audio/${audioId}`;
|
return `${this.getBaseUrl()}/audio/${audioId}`;
|
||||||
@@ -309,11 +327,34 @@ class ApiClient {
|
|||||||
return this.request<ModelStatusListResponse>('/models/status');
|
return this.request<ModelStatusListResponse>('/models/status');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getModelsCacheDir(): Promise<{ path: string }> {
|
||||||
|
return this.request<{ path: string }>('/models/cache-dir');
|
||||||
|
}
|
||||||
|
|
||||||
|
async migrateModels(destination: string): Promise<{ source: string; destination: string }> {
|
||||||
|
return this.request('/models/migrate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ destination }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getMigrationProgressUrl(): string {
|
||||||
|
return `${this.getBaseUrl()}/models/migrate/progress`;
|
||||||
|
}
|
||||||
|
|
||||||
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
|
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
|
||||||
return this.request<{ message: string }>('/models/download', {
|
console.log(
|
||||||
|
'[API] triggerModelDownload called for:',
|
||||||
|
modelName,
|
||||||
|
'at',
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
const result = await this.request<{ message: string }>('/models/download', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
||||||
});
|
});
|
||||||
|
console.log('[API] triggerModelDownload response:', result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteModel(modelName: string): Promise<{ message: string }> {
|
async deleteModel(modelName: string): Promise<{ message: string }> {
|
||||||
@@ -322,11 +363,28 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async unloadModel(modelName: string): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>(`/models/${modelName}/unload`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelDownload(modelName: string): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>('/models/download/cancel', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Task Management
|
// Task Management
|
||||||
async getActiveTasks(): Promise<ActiveTasksResponse> {
|
async getActiveTasks(): Promise<ActiveTasksResponse> {
|
||||||
return this.request<ActiveTasksResponse>('/tasks/active');
|
return this.request<ActiveTasksResponse>('/tasks/active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearAllTasks(): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>('/tasks/clear', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
// Audio Channels
|
// Audio Channels
|
||||||
async listChannels(): Promise<
|
async listChannels(): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
@@ -340,10 +398,7 @@ class ApiClient {
|
|||||||
return this.request('/channels');
|
return this.request('/channels');
|
||||||
}
|
}
|
||||||
|
|
||||||
async createChannel(data: {
|
async createChannel(data: { name: string; device_ids: string[] }): Promise<{
|
||||||
name: string;
|
|
||||||
device_ids: string[];
|
|
||||||
}): Promise<{
|
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
@@ -385,10 +440,7 @@ class ApiClient {
|
|||||||
return this.request(`/channels/${channelId}/voices`);
|
return this.request(`/channels/${channelId}/voices`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setChannelVoices(
|
async setChannelVoices(channelId: string, profileIds: string[]): Promise<{ message: string }> {
|
||||||
channelId: string,
|
|
||||||
profileIds: string[],
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
return this.request(`/channels/${channelId}/voices`, {
|
return this.request(`/channels/${channelId}/voices`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ profile_ids: profileIds }),
|
body: JSON.stringify({ profile_ids: profileIds }),
|
||||||
@@ -399,16 +451,30 @@ class ApiClient {
|
|||||||
return this.request(`/profiles/${profileId}/channels`);
|
return this.request(`/profiles/${profileId}/channels`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setProfileChannels(
|
async setProfileChannels(profileId: string, channelIds: string[]): Promise<{ message: string }> {
|
||||||
profileId: string,
|
|
||||||
channelIds: string[],
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
return this.request(`/profiles/${profileId}/channels`, {
|
return this.request(`/profiles/${profileId}/channels`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ channel_ids: channelIds }),
|
body: JSON.stringify({ channel_ids: channelIds }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CUDA Backend Management
|
||||||
|
async getCudaStatus(): Promise<CudaStatus> {
|
||||||
|
return this.request<CudaStatus>('/backend/cuda-status');
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadCudaBackend(): Promise<{ message: string; progress_key: string }> {
|
||||||
|
return this.request<{ message: string; progress_key: string }>('/backend/download-cuda', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCudaBackend(): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>('/backend/cuda', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Stories
|
// Stories
|
||||||
async listStories(): Promise<StoryResponse[]> {
|
async listStories(): Promise<StoryResponse[]> {
|
||||||
return this.request<StoryResponse[]>('/stories');
|
return this.request<StoryResponse[]>('/stories');
|
||||||
@@ -465,21 +531,33 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveStoryItem(storyId: string, itemId: string, data: StoryItemMove): Promise<StoryItemDetail> {
|
async moveStoryItem(
|
||||||
|
storyId: string,
|
||||||
|
itemId: string,
|
||||||
|
data: StoryItemMove,
|
||||||
|
): Promise<StoryItemDetail> {
|
||||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async trimStoryItem(storyId: string, itemId: string, data: StoryItemTrim): Promise<StoryItemDetail> {
|
async trimStoryItem(
|
||||||
|
storyId: string,
|
||||||
|
itemId: string,
|
||||||
|
data: StoryItemTrim,
|
||||||
|
): Promise<StoryItemDetail> {
|
||||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/trim`, {
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/trim`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async splitStoryItem(storyId: string, itemId: string, data: StoryItemSplit): Promise<StoryItemDetail[]> {
|
async splitStoryItem(
|
||||||
|
storyId: string,
|
||||||
|
itemId: string,
|
||||||
|
data: StoryItemSplit,
|
||||||
|
): Promise<StoryItemDetail[]> {
|
||||||
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/${itemId}/split`, {
|
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/${itemId}/split`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type ModelStatus = {
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
|
downloading?: boolean; // True if download is in progress
|
||||||
size_mb?: number | null;
|
size_mb?: number | null;
|
||||||
loaded?: boolean;
|
loaded?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export interface GenerationRequest {
|
|||||||
language: LanguageCode;
|
language: LanguageCode;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
model_size?: '1.7B' | '0.6B';
|
model_size?: '1.7B' | '0.6B';
|
||||||
|
engine?: 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo';
|
||||||
|
instruct?: string;
|
||||||
|
max_chunk_chars?: number;
|
||||||
|
crossfade_ms?: number;
|
||||||
|
normalize?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerationResponse {
|
export interface GenerationResponse {
|
||||||
@@ -41,9 +46,14 @@ export interface GenerationResponse {
|
|||||||
profile_id: string;
|
profile_id: string;
|
||||||
text: string;
|
text: string;
|
||||||
language: string;
|
language: string;
|
||||||
audio_path: string;
|
audio_path?: string;
|
||||||
duration: number;
|
duration?: number;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
|
instruct?: string;
|
||||||
|
engine?: string;
|
||||||
|
model_size?: string;
|
||||||
|
status: 'generating' | 'completed' | 'failed';
|
||||||
|
error?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +88,29 @@ export interface HealthResponse {
|
|||||||
model_downloaded?: boolean;
|
model_downloaded?: boolean;
|
||||||
model_size?: string;
|
model_size?: string;
|
||||||
gpu_available: boolean;
|
gpu_available: boolean;
|
||||||
|
gpu_type?: string;
|
||||||
vram_used_mb?: number;
|
vram_used_mb?: number;
|
||||||
|
backend_type?: string;
|
||||||
|
backend_variant?: string; // "cpu" or "cuda"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CudaDownloadProgress {
|
||||||
|
model_name: string;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
progress: number;
|
||||||
|
filename?: string;
|
||||||
|
status: 'downloading' | 'extracting' | 'complete' | 'error';
|
||||||
|
timestamp: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CudaStatus {
|
||||||
|
available: boolean; // CUDA binary exists on disk
|
||||||
|
active: boolean; // Currently running the CUDA binary
|
||||||
|
binary_path?: string;
|
||||||
|
downloading: boolean; // Download in progress
|
||||||
|
download_progress?: CudaDownloadProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelProgress {
|
export interface ModelProgress {
|
||||||
@@ -95,11 +127,29 @@ export interface ModelProgress {
|
|||||||
export interface ModelStatus {
|
export interface ModelStatus {
|
||||||
model_name: string;
|
model_name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
|
hf_repo_id?: string; // HuggingFace repository ID
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
|
downloading: boolean; // True if download is in progress
|
||||||
size_mb?: number;
|
size_mb?: number;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HuggingFaceModelInfo {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
lastModified: string;
|
||||||
|
pipeline_tag?: string;
|
||||||
|
library_name?: string;
|
||||||
|
downloads: number;
|
||||||
|
likes: number;
|
||||||
|
tags: string[];
|
||||||
|
cardData?: {
|
||||||
|
license?: string;
|
||||||
|
language?: string[];
|
||||||
|
pipeline_tag?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModelStatusListResponse {
|
export interface ModelStatusListResponse {
|
||||||
models: ModelStatus[];
|
models: ModelStatus[];
|
||||||
}
|
}
|
||||||
@@ -112,6 +162,11 @@ export interface ActiveDownloadTask {
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
status: string;
|
status: string;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
|
error?: string;
|
||||||
|
progress?: number; // 0-100 percentage
|
||||||
|
current?: number; // bytes downloaded
|
||||||
|
total?: number; // total bytes
|
||||||
|
filename?: string; // current file being downloaded
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveGenerationTask {
|
export interface ActiveGenerationTask {
|
||||||
|
|||||||
@@ -1,26 +1,86 @@
|
|||||||
/**
|
/**
|
||||||
* Supported languages for Qwen3-TTS
|
* Supported languages for voice generation, per engine.
|
||||||
* Based on: https://github.com/QwenLM/Qwen3-TTS
|
*
|
||||||
|
* Qwen3-TTS supports 10 languages.
|
||||||
|
* LuxTTS is English-only.
|
||||||
|
* Chatterbox Multilingual supports 23 languages.
|
||||||
|
* Chatterbox Turbo is English-only.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const SUPPORTED_LANGUAGES = {
|
/** All languages that any engine supports. */
|
||||||
zh: 'Chinese',
|
export const ALL_LANGUAGES = {
|
||||||
|
ar: 'Arabic',
|
||||||
|
da: 'Danish',
|
||||||
|
de: 'German',
|
||||||
|
el: 'Greek',
|
||||||
en: 'English',
|
en: 'English',
|
||||||
|
es: 'Spanish',
|
||||||
|
fi: 'Finnish',
|
||||||
|
fr: 'French',
|
||||||
|
he: 'Hebrew',
|
||||||
|
hi: 'Hindi',
|
||||||
|
it: 'Italian',
|
||||||
ja: 'Japanese',
|
ja: 'Japanese',
|
||||||
ko: 'Korean',
|
ko: 'Korean',
|
||||||
de: 'German',
|
ms: 'Malay',
|
||||||
fr: 'French',
|
nl: 'Dutch',
|
||||||
ru: 'Russian',
|
no: 'Norwegian',
|
||||||
|
pl: 'Polish',
|
||||||
pt: 'Portuguese',
|
pt: 'Portuguese',
|
||||||
es: 'Spanish',
|
ru: 'Russian',
|
||||||
it: 'Italian',
|
sv: 'Swedish',
|
||||||
|
sw: 'Swahili',
|
||||||
|
tr: 'Turkish',
|
||||||
|
zh: 'Chinese',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type LanguageCode = keyof typeof SUPPORTED_LANGUAGES;
|
export type LanguageCode = keyof typeof ALL_LANGUAGES;
|
||||||
|
|
||||||
export const LANGUAGE_CODES = Object.keys(SUPPORTED_LANGUAGES) as LanguageCode[];
|
/** Per-engine supported language codes. */
|
||||||
|
export const ENGINE_LANGUAGES: Record<string, readonly LanguageCode[]> = {
|
||||||
|
qwen: ['zh', 'en', 'ja', 'ko', 'de', 'fr', 'ru', 'pt', 'es', 'it'],
|
||||||
|
luxtts: ['en'],
|
||||||
|
chatterbox: [
|
||||||
|
'ar',
|
||||||
|
'da',
|
||||||
|
'de',
|
||||||
|
'el',
|
||||||
|
'en',
|
||||||
|
'es',
|
||||||
|
'fi',
|
||||||
|
'fr',
|
||||||
|
'he',
|
||||||
|
'hi',
|
||||||
|
'it',
|
||||||
|
'ja',
|
||||||
|
'ko',
|
||||||
|
'ms',
|
||||||
|
'nl',
|
||||||
|
'no',
|
||||||
|
'pl',
|
||||||
|
'pt',
|
||||||
|
'ru',
|
||||||
|
'sv',
|
||||||
|
'sw',
|
||||||
|
'tr',
|
||||||
|
'zh',
|
||||||
|
],
|
||||||
|
chatterbox_turbo: ['en'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Helper: get language options for a given engine. */
|
||||||
|
export function getLanguageOptionsForEngine(engine: string) {
|
||||||
|
const codes = ENGINE_LANGUAGES[engine] ?? ENGINE_LANGUAGES.qwen;
|
||||||
|
return codes.map((code) => ({
|
||||||
|
value: code,
|
||||||
|
label: ALL_LANGUAGES[code],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backwards-compatible exports used elsewhere ──────────────────────
|
||||||
|
export const SUPPORTED_LANGUAGES = ALL_LANGUAGES;
|
||||||
|
export const LANGUAGE_CODES = Object.keys(ALL_LANGUAGES) as LanguageCode[];
|
||||||
export const LANGUAGE_OPTIONS = LANGUAGE_CODES.map((code) => ({
|
export const LANGUAGE_OPTIONS = LANGUAGE_CODES.map((code) => ({
|
||||||
value: code,
|
value: code,
|
||||||
label: SUPPORTED_LANGUAGES[code],
|
label: ALL_LANGUAGES[code],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ export function useAudioRecording({
|
|||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
const startTimeRef = useRef<number | null>(null);
|
const startTimeRef = useRef<number | null>(null);
|
||||||
|
const cancelledRef = useRef<boolean>(false);
|
||||||
|
|
||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
|
cancelledRef.current = false;
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
|
|
||||||
// Check if getUserMedia is available
|
// Check if getUserMedia is available
|
||||||
@@ -87,31 +89,34 @@ export function useAudioRecording({
|
|||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.onstop = async () => {
|
mediaRecorder.onstop = async () => {
|
||||||
|
// Snapshot the cancellation flag and recorded duration immediately —
|
||||||
|
// cancelRecording() clears chunks and sets cancelledRef synchronously
|
||||||
|
// before this async handler runs, so we must check it first.
|
||||||
|
const wasCancelled = cancelledRef.current;
|
||||||
|
const recordedDuration = startTimeRef.current
|
||||||
|
? (Date.now() - startTimeRef.current) / 1000
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||||
|
|
||||||
// Convert to WAV format to avoid needing ffmpeg on backend
|
// Stop all tracks now that we have the data
|
||||||
try {
|
|
||||||
const wavBlob = await convertToWav(webmBlob);
|
|
||||||
|
|
||||||
// Pass the actual recorded duration
|
|
||||||
const recordedDuration = startTimeRef.current
|
|
||||||
? (Date.now() - startTimeRef.current) / 1000
|
|
||||||
: undefined;
|
|
||||||
onRecordingComplete?.(wavBlob, recordedDuration);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error converting audio to WAV:', err);
|
|
||||||
// Fallback to original blob if conversion fails
|
|
||||||
const recordedDuration = startTimeRef.current
|
|
||||||
? (Date.now() - startTimeRef.current) / 1000
|
|
||||||
: undefined;
|
|
||||||
onRecordingComplete?.(webmBlob, recordedDuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop all tracks
|
|
||||||
streamRef.current?.getTracks().forEach((track) => {
|
streamRef.current?.getTracks().forEach((track) => {
|
||||||
track.stop();
|
track.stop();
|
||||||
});
|
});
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
|
|
||||||
|
// Don't fire completion callback if the recording was cancelled
|
||||||
|
if (wasCancelled) return;
|
||||||
|
|
||||||
|
// Convert to WAV format to avoid needing ffmpeg on backend
|
||||||
|
try {
|
||||||
|
const wavBlob = await convertToWav(webmBlob);
|
||||||
|
onRecordingComplete?.(wavBlob, recordedDuration);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error converting audio to WAV:', err);
|
||||||
|
// Fallback to original blob if conversion fails
|
||||||
|
onRecordingComplete?.(webmBlob, recordedDuration);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.onerror = (event) => {
|
mediaRecorder.onerror = (event) => {
|
||||||
@@ -167,9 +172,10 @@ export function useAudioRecording({
|
|||||||
|
|
||||||
const cancelRecording = useCallback(() => {
|
const cancelRecording = useCallback(() => {
|
||||||
if (mediaRecorderRef.current) {
|
if (mediaRecorderRef.current) {
|
||||||
|
cancelledRef.current = true; // Must be set before stop() triggers onstop
|
||||||
|
chunksRef.current = [];
|
||||||
mediaRecorderRef.current.stop();
|
mediaRecorderRef.current.stop();
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
chunksRef.current = [];
|
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
|
|||||||
import { useGeneration } from '@/lib/hooks/useGeneration';
|
import { useGeneration } from '@/lib/hooks/useGeneration';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
const generationSchema = z.object({
|
const generationSchema = z.object({
|
||||||
text: z.string().min(1, 'Text is required').max(5000),
|
text: z.string().min(1, 'Text is required').max(50000),
|
||||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||||
seed: z.number().int().optional(),
|
seed: z.number().int().optional(),
|
||||||
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
||||||
instruct: z.string().max(500).optional(),
|
instruct: z.string().max(500).optional(),
|
||||||
|
engine: z.enum(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GenerationFormValues = z.infer<typeof generationSchema>;
|
export type GenerationFormValues = z.infer<typeof generationSchema>;
|
||||||
@@ -28,8 +29,10 @@ interface UseGenerationFormOptions {
|
|||||||
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const generation = useGeneration();
|
const generation = useGeneration();
|
||||||
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||||
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
|
||||||
|
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
||||||
|
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
|
||||||
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
||||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
seed: undefined,
|
seed: undefined,
|
||||||
modelSize: '1.7B',
|
modelSize: '1.7B',
|
||||||
instruct: '',
|
instruct: '',
|
||||||
|
engine: 'qwen',
|
||||||
...options.defaultValues,
|
...options.defaultValues,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -65,11 +69,27 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsGenerating(true);
|
const engine = data.engine || 'qwen';
|
||||||
|
const modelName =
|
||||||
const modelName = `qwen-tts-${data.modelSize}`;
|
engine === 'luxtts'
|
||||||
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
|
? 'luxtts'
|
||||||
|
: engine === 'chatterbox'
|
||||||
|
? 'chatterbox-tts'
|
||||||
|
: engine === 'chatterbox_turbo'
|
||||||
|
? 'chatterbox-turbo'
|
||||||
|
: `qwen-tts-${data.modelSize}`;
|
||||||
|
const displayName =
|
||||||
|
engine === 'luxtts'
|
||||||
|
? 'LuxTTS'
|
||||||
|
: engine === 'chatterbox'
|
||||||
|
? 'Chatterbox TTS'
|
||||||
|
: engine === 'chatterbox_turbo'
|
||||||
|
? 'Chatterbox Turbo'
|
||||||
|
: data.modelSize === '1.7B'
|
||||||
|
? 'Qwen TTS 1.7B'
|
||||||
|
: 'Qwen TTS 0.6B';
|
||||||
|
|
||||||
|
// Check if model needs downloading
|
||||||
try {
|
try {
|
||||||
const modelStatus = await apiClient.getModelStatus();
|
const modelStatus = await apiClient.getModelStatus();
|
||||||
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
||||||
@@ -82,24 +102,33 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
console.error('Failed to check model status:', error);
|
console.error('Failed to check model status:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isQwen = engine === 'qwen';
|
||||||
|
// This now returns immediately with status="generating"
|
||||||
const result = await generation.mutateAsync({
|
const result = await generation.mutateAsync({
|
||||||
profile_id: selectedProfileId,
|
profile_id: selectedProfileId,
|
||||||
text: data.text,
|
text: data.text,
|
||||||
language: data.language,
|
language: data.language,
|
||||||
seed: data.seed,
|
seed: data.seed,
|
||||||
model_size: data.modelSize,
|
model_size: isQwen ? data.modelSize : undefined,
|
||||||
instruct: data.instruct || undefined,
|
engine,
|
||||||
|
instruct: isQwen ? data.instruct || undefined : undefined,
|
||||||
|
max_chunk_chars: maxChunkChars,
|
||||||
|
crossfade_ms: crossfadeMs,
|
||||||
|
normalize: normalizeAudio,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
// Track this generation for SSE status updates
|
||||||
title: 'Generation complete!',
|
addPendingGeneration(result.id);
|
||||||
description: `Audio generated (${result.duration.toFixed(2)}s)`,
|
|
||||||
|
// Reset form immediately — user can start typing again
|
||||||
|
form.reset({
|
||||||
|
text: '',
|
||||||
|
language: data.language,
|
||||||
|
seed: undefined,
|
||||||
|
modelSize: data.modelSize,
|
||||||
|
instruct: '',
|
||||||
|
engine: data.engine,
|
||||||
});
|
});
|
||||||
|
|
||||||
const audioUrl = apiClient.getAudioUrl(result.id);
|
|
||||||
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
options.onSuccess?.(result.id);
|
options.onSuccess?.(result.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
@@ -108,7 +137,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsGenerating(false);
|
|
||||||
setDownloadingModelName(null);
|
setDownloadingModelName(null);
|
||||||
setDownloadingDisplayName(null);
|
setDownloadingDisplayName(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
|
interface GenerationStatusEvent {
|
||||||
|
id: string;
|
||||||
|
status: 'generating' | 'completed' | 'failed' | 'not_found';
|
||||||
|
duration?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to SSE for all pending generations. When a generation completes,
|
||||||
|
* invalidates the history query, removes it from pending, and auto-plays
|
||||||
|
* if the player is idle.
|
||||||
|
*/
|
||||||
|
export function useGenerationProgress() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const pendingIds = useGenerationStore((s) => s.pendingGenerationIds);
|
||||||
|
const removePendingGeneration = useGenerationStore((s) => s.removePendingGeneration);
|
||||||
|
const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd);
|
||||||
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||||
|
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
|
||||||
|
const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
|
||||||
|
|
||||||
|
// Keep refs to avoid stale closures in EventSource handlers
|
||||||
|
const isPlayingRef = useRef(isPlaying);
|
||||||
|
const autoplayRef = useRef(autoplayOnGenerate);
|
||||||
|
isPlayingRef.current = isPlaying;
|
||||||
|
autoplayRef.current = autoplayOnGenerate;
|
||||||
|
|
||||||
|
// Track active EventSource instances
|
||||||
|
const eventSourcesRef = useRef<Map<string, EventSource>>(new Map());
|
||||||
|
|
||||||
|
// Unmount-only cleanup — close all SSE connections when the hook is torn down
|
||||||
|
useEffect(() => {
|
||||||
|
const sources = eventSourcesRef.current;
|
||||||
|
return () => {
|
||||||
|
for (const source of sources.values()) {
|
||||||
|
source.close();
|
||||||
|
}
|
||||||
|
sources.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentSources = eventSourcesRef.current;
|
||||||
|
|
||||||
|
// Close SSE connections for IDs no longer pending
|
||||||
|
for (const [id, source] of currentSources.entries()) {
|
||||||
|
if (!pendingIds.has(id)) {
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open SSE connections for new pending IDs
|
||||||
|
for (const id of pendingIds) {
|
||||||
|
if (currentSources.has(id)) continue;
|
||||||
|
|
||||||
|
const url = apiClient.getGenerationStatusUrl(id);
|
||||||
|
const source = new EventSource(url);
|
||||||
|
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data: GenerationStatusEvent = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
removePendingGeneration(id);
|
||||||
|
|
||||||
|
// Refresh history to pick up the completed generation
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
|
||||||
|
// If this generation was queued for a story, add it now
|
||||||
|
const storyId = removePendingStoryAdd(id);
|
||||||
|
if (storyId) {
|
||||||
|
apiClient
|
||||||
|
.addStoryItem(storyId, { generation_id: id })
|
||||||
|
.then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', storyId] });
|
||||||
|
toast({
|
||||||
|
title: 'Added to story',
|
||||||
|
description: data.duration
|
||||||
|
? `Audio generated (${data.duration.toFixed(2)}s) and added to story`
|
||||||
|
: 'Audio generated and added to story',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast({
|
||||||
|
title: 'Generation complete',
|
||||||
|
description: 'Audio generated but failed to add to story',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// toast({
|
||||||
|
// title: 'Generation complete!',
|
||||||
|
// description: data.duration
|
||||||
|
// ? `Audio generated (${data.duration.toFixed(2)}s)`
|
||||||
|
// : 'Audio generated',
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-play if enabled and nothing is currently playing
|
||||||
|
if (autoplayRef.current && !isPlayingRef.current) {
|
||||||
|
const genAudioUrl = apiClient.getAudioUrl(id);
|
||||||
|
setAudioWithAutoPlay(genAudioUrl, id, '', '');
|
||||||
|
}
|
||||||
|
} else if (data.status === 'failed' || data.status === 'not_found') {
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
removePendingGeneration(id);
|
||||||
|
removePendingStoryAdd(id);
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: data.status === 'not_found' ? 'Generation not found' : 'Generation failed',
|
||||||
|
description: data.error || 'An error occurred during generation',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors from heartbeats etc
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.onerror = () => {
|
||||||
|
// EventSource auto-reconnects, but if we get repeated errors
|
||||||
|
// just clean up
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
removePendingGeneration(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
currentSources.set(id, source);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
pendingIds,
|
||||||
|
removePendingGeneration,
|
||||||
|
removePendingStoryAdd,
|
||||||
|
queryClient,
|
||||||
|
toast,
|
||||||
|
setAudioWithAutoPlay,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import type { ModelProgress } from '@/lib/api/types';
|
import type { ModelProgress } from '@/lib/api/types';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
interface UseModelDownloadToastOptions {
|
interface UseModelDownloadToastOptions {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
onComplete?: () => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,47 +21,64 @@ export function useModelDownloadToast({
|
|||||||
modelName,
|
modelName,
|
||||||
displayName,
|
displayName,
|
||||||
enabled = false,
|
enabled = false,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
}: UseModelDownloadToastOptions) {
|
}: UseModelDownloadToastOptions) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
const toastIdRef = useRef<string | null>(null);
|
const toastIdRef = useRef<string | null>(null);
|
||||||
const toastUpdateRef = useRef<
|
// biome-ignore lint: Using any for toast update ref to handle complex toast types
|
||||||
((props: {
|
const toastUpdateRef = useRef<any>(null);
|
||||||
title?: React.ReactNode;
|
|
||||||
description?: React.ReactNode;
|
|
||||||
duration?: number;
|
|
||||||
variant?: 'default' | 'destructive';
|
|
||||||
open?: boolean;
|
|
||||||
}) => void) | null
|
|
||||||
>(null);
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
const formatBytes = (bytes: number): string => {
|
const formatBytes = useCallback((bytes: number): string => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log('[useModelDownloadToast] useEffect triggered', {
|
||||||
|
enabled,
|
||||||
|
serverUrl,
|
||||||
|
modelName,
|
||||||
|
displayName,
|
||||||
|
});
|
||||||
|
|
||||||
if (!enabled || !serverUrl || !modelName) {
|
if (!enabled || !serverUrl || !modelName) {
|
||||||
|
console.log('[useModelDownloadToast] Not enabled, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[useModelDownloadToast] Creating toast and EventSource for:', modelName);
|
||||||
|
|
||||||
// Create initial toast
|
// Create initial toast
|
||||||
const toastResult = toast({
|
const toastResult = toast({
|
||||||
title: displayName,
|
title: displayName,
|
||||||
description: 'Starting download...',
|
description: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span>Connecting to download...</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
duration: Infinity, // Don't auto-dismiss, we'll handle it manually
|
duration: Infinity, // Don't auto-dismiss, we'll handle it manually
|
||||||
});
|
});
|
||||||
toastIdRef.current = toastResult.id;
|
toastIdRef.current = toastResult.id;
|
||||||
toastUpdateRef.current = toastResult.update;
|
toastUpdateRef.current = toastResult.update;
|
||||||
|
|
||||||
// Subscribe to progress updates via Server-Sent Events
|
// Subscribe to progress updates via Server-Sent Events
|
||||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
const eventSourceUrl = `${serverUrl}/models/progress/${modelName}`;
|
||||||
|
console.log('[useModelDownloadToast] Creating EventSource to:', eventSourceUrl);
|
||||||
|
const eventSource = new EventSource(eventSourceUrl);
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
console.log('[useModelDownloadToast] EventSource connection opened for:', modelName);
|
||||||
|
};
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
|
console.log('[useModelDownloadToast] Received SSE message:', event.data);
|
||||||
try {
|
try {
|
||||||
const progress = JSON.parse(event.data) as ModelProgress;
|
const progress = JSON.parse(event.data) as ModelProgress;
|
||||||
|
|
||||||
@@ -82,11 +101,11 @@ export function useModelDownloadToast({
|
|||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
statusIcon = <XCircle className="h-4 w-4 text-destructive" />;
|
statusIcon = <XCircle className="h-4 w-4 text-destructive" />;
|
||||||
statusText = `Error: ${progress.error || 'Unknown error'}`;
|
statusText = 'Download failed. See Problems panel for details.';
|
||||||
break;
|
break;
|
||||||
case 'downloading':
|
case 'downloading':
|
||||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||||
statusText = progress.filename ? `Downloading ${progress.filename}...` : 'Downloading...';
|
statusText = progress.filename || 'Downloading...';
|
||||||
break;
|
break;
|
||||||
case 'extracting':
|
case 'extracting':
|
||||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||||
@@ -112,26 +131,44 @@ export function useModelDownloadToast({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
duration: progress.status === 'complete' ? 5000 : Infinity,
|
duration: progress.status === 'complete' || progress.status === 'error' ? 5000 : Infinity,
|
||||||
variant: progress.status === 'error' ? 'destructive' : 'default',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close connection and dismiss toast on completion or error
|
// Close connection and dismiss toast on completion or error
|
||||||
if (progress.status === 'complete' || progress.status === 'error') {
|
// Also treat progress >= 100% as complete
|
||||||
|
const isComplete = progress.status === 'complete' || progress.progress >= 100;
|
||||||
|
const isError = progress.status === 'error';
|
||||||
|
|
||||||
|
if (isComplete || isError) {
|
||||||
|
console.log('[useModelDownloadToast] Download finished:', {
|
||||||
|
isComplete,
|
||||||
|
isError,
|
||||||
|
progress: progress.progress,
|
||||||
|
});
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
// Auto-dismiss on completion after delay
|
// Update toast to show completion state before callbacks
|
||||||
if (progress.status === 'complete') {
|
if (isComplete && toastUpdateRef.current) {
|
||||||
setTimeout(() => {
|
toastUpdateRef.current({
|
||||||
if (toastIdRef.current && toastUpdateRef.current) {
|
title: (
|
||||||
toastUpdateRef.current({
|
<div className="flex items-center gap-2">
|
||||||
open: false,
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
});
|
<span>{displayName}</span>
|
||||||
toastIdRef.current = null;
|
</div>
|
||||||
toastUpdateRef.current = null;
|
),
|
||||||
}
|
description: 'Download complete',
|
||||||
}, 5000);
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call callbacks
|
||||||
|
if (isComplete && onComplete) {
|
||||||
|
console.log('[useModelDownloadToast] Download complete, calling onComplete callback');
|
||||||
|
onComplete();
|
||||||
|
} else if (isError && onError) {
|
||||||
|
console.log('[useModelDownloadToast] Download error, calling onError callback');
|
||||||
|
onError(progress.error || 'Unknown error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +178,8 @@ export function useModelDownloadToast({
|
|||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error('SSE error:', error);
|
console.error('[useModelDownloadToast] SSE error for:', modelName, error);
|
||||||
|
console.log('[useModelDownloadToast] EventSource readyState:', eventSource.readyState);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
@@ -162,15 +200,16 @@ export function useModelDownloadToast({
|
|||||||
|
|
||||||
// Cleanup on unmount or when disabled
|
// Cleanup on unmount or when disabled
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log('[useModelDownloadToast] Cleanup - closing EventSource for:', modelName);
|
||||||
if (eventSourceRef.current) {
|
if (eventSourceRef.current) {
|
||||||
eventSourceRef.current.close();
|
eventSourceRef.current.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
}
|
}
|
||||||
// Note: We don't dismiss the toast here as it might still be showing completion state
|
// Note: We don't dismiss the toast here as it might still be showing completion state
|
||||||
};
|
};
|
||||||
}, [enabled, serverUrl, modelName, displayName, toast]);
|
}, [enabled, serverUrl, modelName, displayName, toast, formatBytes, onComplete, onError]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isTracking: enabled && eventSourceRef.current !== null,
|
isTracking: enabled && eventSourceRef.current !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
|
||||||
import type { ActiveDownloadTask } from '@/lib/api/types';
|
import type { ActiveDownloadTask } from '@/lib/api/types';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
|
||||||
// Polling interval in milliseconds
|
// Polling interval in milliseconds
|
||||||
const POLL_INTERVAL = 2000;
|
const POLL_INTERVAL = 30000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to monitor active tasks (downloads and generations).
|
* Hook to monitor active tasks (downloads and generations).
|
||||||
* Polls the server periodically to catch downloads triggered from anywhere
|
* Polls the server periodically to catch downloads triggered from anywhere
|
||||||
* (transcription, generation, explicit download, etc.).
|
* (transcription, generation, explicit download, etc.).
|
||||||
*
|
*
|
||||||
* Returns the active downloads so components can render download toasts.
|
* Returns the active downloads so components can render download toasts.
|
||||||
*/
|
*/
|
||||||
export function useRestoreActiveTasks() {
|
export function useRestoreActiveTasks() {
|
||||||
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
|
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
|
||||||
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
|
||||||
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
|
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
|
||||||
|
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||||
|
|
||||||
// Track which downloads we've seen to detect new ones
|
// Track which downloads we've seen to detect new ones
|
||||||
const seenDownloadsRef = useRef<Set<string>>(new Set());
|
const seenDownloadsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
@@ -25,15 +25,15 @@ export function useRestoreActiveTasks() {
|
|||||||
try {
|
try {
|
||||||
const tasks = await apiClient.getActiveTasks();
|
const tasks = await apiClient.getActiveTasks();
|
||||||
|
|
||||||
// Update generation state
|
// Restore pending generations (e.g., after page refresh)
|
||||||
if (tasks.generations.length > 0) {
|
if (tasks.generations.length > 0) {
|
||||||
setIsGenerating(true);
|
|
||||||
setActiveGenerationId(tasks.generations[0].task_id);
|
setActiveGenerationId(tasks.generations[0].task_id);
|
||||||
|
for (const gen of tasks.generations) {
|
||||||
|
addPendingGeneration(gen.task_id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Only clear if we were tracking a generation
|
|
||||||
const currentId = useGenerationStore.getState().activeGenerationId;
|
const currentId = useGenerationStore.getState().activeGenerationId;
|
||||||
if (currentId) {
|
if (currentId) {
|
||||||
setIsGenerating(false);
|
|
||||||
setActiveGenerationId(null);
|
setActiveGenerationId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,14 +41,14 @@ export function useRestoreActiveTasks() {
|
|||||||
// Update active downloads
|
// Update active downloads
|
||||||
// Keep track of all active downloads (including new ones)
|
// Keep track of all active downloads (including new ones)
|
||||||
const currentDownloadNames = new Set(tasks.downloads.map((d) => d.model_name));
|
const currentDownloadNames = new Set(tasks.downloads.map((d) => d.model_name));
|
||||||
|
|
||||||
// Remove completed downloads from our seen set
|
// Remove completed downloads from our seen set
|
||||||
for (const name of seenDownloadsRef.current) {
|
for (const name of seenDownloadsRef.current) {
|
||||||
if (!currentDownloadNames.has(name)) {
|
if (!currentDownloadNames.has(name)) {
|
||||||
seenDownloadsRef.current.delete(name);
|
seenDownloadsRef.current.delete(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new downloads to seen set
|
// Add new downloads to seen set
|
||||||
for (const download of tasks.downloads) {
|
for (const download of tasks.downloads) {
|
||||||
seenDownloadsRef.current.add(download.model_name);
|
seenDownloadsRef.current.add(download.model_name);
|
||||||
@@ -59,7 +59,7 @@ export function useRestoreActiveTasks() {
|
|||||||
// Silently fail - server might be temporarily unavailable
|
// Silently fail - server might be temporarily unavailable
|
||||||
console.debug('Failed to fetch active tasks:', error);
|
console.debug('Failed to fetch active tasks:', error);
|
||||||
}
|
}
|
||||||
}, [setIsGenerating, setActiveGenerationId]);
|
}, [setActiveGenerationId, addPendingGeneration]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch immediately on mount
|
// Fetch immediately on mount
|
||||||
|
|||||||
+36
-18
@@ -22,6 +22,11 @@ export function formatAudioDuration(seconds: number): string {
|
|||||||
* If the file has a recordedDuration property (from recording hooks),
|
* If the file has a recordedDuration property (from recording hooks),
|
||||||
* use that instead of trying to read metadata. This fixes issues on Windows
|
* use that instead of trying to read metadata. This fixes issues on Windows
|
||||||
* where WebM files from MediaRecorder don't have proper duration metadata.
|
* where WebM files from MediaRecorder don't have proper duration metadata.
|
||||||
|
*
|
||||||
|
* For uploaded files we use AudioContext.decodeAudioData which fully decodes
|
||||||
|
* the audio and returns the exact duration. This is more reliable than
|
||||||
|
* HTMLMediaElement.duration which can return incorrect large values for VBR
|
||||||
|
* MP3 files that lack a proper XING/VBRI header.
|
||||||
*/
|
*/
|
||||||
export async function getAudioDuration(
|
export async function getAudioDuration(
|
||||||
file: File & { recordedDuration?: number },
|
file: File & { recordedDuration?: number },
|
||||||
@@ -30,26 +35,39 @@ export async function getAudioDuration(
|
|||||||
return file.recordedDuration;
|
return file.recordedDuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
// Use Web Audio API for accurate duration — avoids VBR MP3 metadata issues.
|
||||||
const audio = new Audio();
|
try {
|
||||||
const url = URL.createObjectURL(file);
|
const audioContext = new AudioContext();
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
|
return audioBuffer.duration;
|
||||||
|
} finally {
|
||||||
|
await audioContext.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback: read duration from the media element (less accurate but works for WAV).
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const audio = new Audio();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
audio.addEventListener('loadedmetadata', () => {
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
||||||
resolve(audio.duration);
|
resolve(audio.duration);
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('Audio file has invalid duration metadata'));
|
reject(new Error('Audio file has invalid duration metadata'));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.addEventListener('error', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('Failed to load audio file'));
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.src = url;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
audio.addEventListener('error', () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
reject(new Error('Failed to load audio file'));
|
|
||||||
});
|
|
||||||
|
|
||||||
audio.src = url;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,10 +21,25 @@ export function formatDate(date: string | Date): string {
|
|||||||
} else {
|
} else {
|
||||||
dateObj = date;
|
dateObj = date;
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
|
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
|
||||||
|
qwen: 'Qwen',
|
||||||
|
luxtts: 'LuxTTS',
|
||||||
|
chatterbox: 'Chatterbox',
|
||||||
|
chatterbox_turbo: 'Chatterbox Turbo',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatEngineName(engine?: string, modelSize?: string): string {
|
||||||
|
const name = ENGINE_DISPLAY_NAMES[engine ?? 'qwen'] ?? engine ?? 'Qwen';
|
||||||
|
if (engine === 'qwen' && modelSize) {
|
||||||
|
return `${name} ${modelSize}`;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatFileSize(bytes: number): string {
|
export function formatFileSize(bytes: number): string {
|
||||||
if (bytes === 0) return '0 Bytes';
|
if (bytes === 0) return '0 Bytes';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ export interface PlatformAudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformLifecycle {
|
export interface PlatformLifecycle {
|
||||||
startServer(remote?: boolean): Promise<string>;
|
startServer(remote?: boolean, modelsDir?: string | null): Promise<string>;
|
||||||
stopServer(): Promise<void>;
|
stopServer(): Promise<void>;
|
||||||
|
restartServer(modelsDir?: string | null): Promise<string>;
|
||||||
setKeepServerRunning(keep: boolean): Promise<void>;
|
setKeepServerRunning(keep: boolean): Promise<void>;
|
||||||
setupWindowCloseHandler(): Promise<void>;
|
setupWindowCloseHandler(): Promise<void>;
|
||||||
onServerReady?: () => void;
|
onServerReady?: () => void;
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { Sidebar } from '@/components/Sidebar';
|
|||||||
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
||||||
|
import { useGenerationProgress } from '@/lib/hooks/useGenerationProgress';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
||||||
|
|
||||||
// Simple platform check that works in both web and Tauri
|
// Simple platform check that works in both web and Tauri
|
||||||
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
||||||
|
|
||||||
@@ -18,6 +20,9 @@ function RootLayout() {
|
|||||||
// Monitor active downloads/generations and show toasts for them
|
// Monitor active downloads/generations and show toasts for them
|
||||||
const activeDownloads = useRestoreActiveTasks();
|
const activeDownloads = useRestoreActiveTasks();
|
||||||
|
|
||||||
|
// Subscribe to SSE for pending generations — handles completion, auto-play, and history refresh
|
||||||
|
useGenerationProgress();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppFrame>
|
<AppFrame>
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
|
|||||||
@@ -1,15 +1,58 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
interface GenerationState {
|
interface GenerationState {
|
||||||
|
/** IDs of generations currently in progress */
|
||||||
|
pendingGenerationIds: Set<string>;
|
||||||
|
/** Whether any generation is in progress (derived from pendingGenerationIds) */
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
activeGenerationId: string | null;
|
/** Map of generationId → storyId for deferred story additions */
|
||||||
setIsGenerating: (generating: boolean) => void;
|
pendingStoryAdds: Map<string, string>;
|
||||||
|
addPendingGeneration: (id: string) => void;
|
||||||
|
removePendingGeneration: (id: string) => void;
|
||||||
|
addPendingStoryAdd: (generationId: string, storyId: string) => void;
|
||||||
|
removePendingStoryAdd: (generationId: string) => string | undefined;
|
||||||
setActiveGenerationId: (id: string | null) => void;
|
setActiveGenerationId: (id: string | null) => void;
|
||||||
|
activeGenerationId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGenerationStore = create<GenerationState>((set) => ({
|
export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||||
|
pendingGenerationIds: new Set(),
|
||||||
isGenerating: false,
|
isGenerating: false,
|
||||||
activeGenerationId: null,
|
activeGenerationId: null,
|
||||||
setIsGenerating: (generating) => set({ isGenerating: generating }),
|
pendingStoryAdds: new Map(),
|
||||||
|
|
||||||
|
addPendingGeneration: (id) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Set(state.pendingGenerationIds);
|
||||||
|
next.add(id);
|
||||||
|
return { pendingGenerationIds: next, isGenerating: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
removePendingGeneration: (id) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Set(state.pendingGenerationIds);
|
||||||
|
next.delete(id);
|
||||||
|
return { pendingGenerationIds: next, isGenerating: next.size > 0 };
|
||||||
|
}),
|
||||||
|
|
||||||
|
addPendingStoryAdd: (generationId, storyId) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.pendingStoryAdds);
|
||||||
|
next.set(generationId, storyId);
|
||||||
|
return { pendingStoryAdds: next };
|
||||||
|
}),
|
||||||
|
|
||||||
|
removePendingStoryAdd: (generationId) => {
|
||||||
|
const storyId = get().pendingStoryAdds.get(generationId);
|
||||||
|
if (storyId) {
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.pendingStoryAdds);
|
||||||
|
next.delete(generationId);
|
||||||
|
return { pendingStoryAdds: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return storyId;
|
||||||
|
},
|
||||||
|
|
||||||
setActiveGenerationId: (id) => set({ activeGenerationId: id }),
|
setActiveGenerationId: (id) => set({ activeGenerationId: id }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -13,6 +13,21 @@ interface ServerStore {
|
|||||||
|
|
||||||
keepServerRunningOnClose: boolean;
|
keepServerRunningOnClose: boolean;
|
||||||
setKeepServerRunningOnClose: (keepRunning: boolean) => void;
|
setKeepServerRunningOnClose: (keepRunning: boolean) => void;
|
||||||
|
|
||||||
|
maxChunkChars: number;
|
||||||
|
setMaxChunkChars: (value: number) => void;
|
||||||
|
|
||||||
|
crossfadeMs: number;
|
||||||
|
setCrossfadeMs: (value: number) => void;
|
||||||
|
|
||||||
|
normalizeAudio: boolean;
|
||||||
|
setNormalizeAudio: (value: boolean) => void;
|
||||||
|
|
||||||
|
autoplayOnGenerate: boolean;
|
||||||
|
setAutoplayOnGenerate: (value: boolean) => void;
|
||||||
|
|
||||||
|
customModelsDir: string | null;
|
||||||
|
setCustomModelsDir: (dir: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useServerStore = create<ServerStore>()(
|
export const useServerStore = create<ServerStore>()(
|
||||||
@@ -29,6 +44,21 @@ export const useServerStore = create<ServerStore>()(
|
|||||||
|
|
||||||
keepServerRunningOnClose: false,
|
keepServerRunningOnClose: false,
|
||||||
setKeepServerRunningOnClose: (keepRunning) => set({ keepServerRunningOnClose: keepRunning }),
|
setKeepServerRunningOnClose: (keepRunning) => set({ keepServerRunningOnClose: keepRunning }),
|
||||||
|
|
||||||
|
maxChunkChars: 800,
|
||||||
|
setMaxChunkChars: (value) => set({ maxChunkChars: value }),
|
||||||
|
|
||||||
|
crossfadeMs: 50,
|
||||||
|
setCrossfadeMs: (value) => set({ crossfadeMs: value }),
|
||||||
|
|
||||||
|
normalizeAudio: true,
|
||||||
|
setNormalizeAudio: (value) => set({ normalizeAudio: value }),
|
||||||
|
|
||||||
|
autoplayOnGenerate: true,
|
||||||
|
setAutoplayOnGenerate: (value) => set({ autoplayOnGenerate: value }),
|
||||||
|
|
||||||
|
customModelsDir: null,
|
||||||
|
setCustomModelsDir: (dir) => set({ customModelsDir: dir }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'voicebox-server',
|
name: 'voicebox-server',
|
||||||
|
|||||||
+12
-9
@@ -334,18 +334,21 @@ python -m backend.main --host 0.0.0.0 --port 8000
|
|||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
|
|
||||||
|
The desktop app, web client, and current development workflow use `http://localhost:17493` by default.
|
||||||
|
If you launch the backend manually with a different host or port, substitute that address in the examples below.
|
||||||
|
|
||||||
### Creating a Voice Profile
|
### Creating a Voice Profile
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Create profile
|
# 1. Create profile
|
||||||
curl -X POST http://localhost:8000/profiles \
|
curl -X POST http://localhost:17493/profiles \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"name": "My Voice", "language": "en"}'
|
-d '{"name": "My Voice", "language": "en"}'
|
||||||
|
|
||||||
# Response: {"id": "abc-123", ...}
|
# Response: {"id": "abc-123", ...}
|
||||||
|
|
||||||
# 2. Add sample
|
# 2. Add sample
|
||||||
curl -X POST http://localhost:8000/profiles/abc-123/samples \
|
curl -X POST http://localhost:17493/profiles/abc-123/samples \
|
||||||
-F "file=@sample.wav" \
|
-F "file=@sample.wav" \
|
||||||
-F "reference_text=This is my voice sample"
|
-F "reference_text=This is my voice sample"
|
||||||
```
|
```
|
||||||
@@ -353,7 +356,7 @@ curl -X POST http://localhost:8000/profiles/abc-123/samples \
|
|||||||
### Generating Speech
|
### Generating Speech
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8000/generate \
|
curl -X POST http://localhost:17493/generate \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"profile_id": "abc-123",
|
"profile_id": "abc-123",
|
||||||
@@ -365,13 +368,13 @@ curl -X POST http://localhost:8000/generate \
|
|||||||
# Response: {"id": "gen-456", "audio_path": "/path/to/audio.wav", ...}
|
# Response: {"id": "gen-456", "audio_path": "/path/to/audio.wav", ...}
|
||||||
|
|
||||||
# Download audio
|
# Download audio
|
||||||
curl http://localhost:8000/audio/gen-456 -o output.wav
|
curl http://localhost:17493/audio/gen-456 -o output.wav
|
||||||
```
|
```
|
||||||
|
|
||||||
### Transcribing Audio
|
### Transcribing Audio
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8000/transcribe \
|
curl -X POST http://localhost:17493/transcribe \
|
||||||
-F "file=@audio.wav" \
|
-F "file=@audio.wav" \
|
||||||
-F "language=en"
|
-F "language=en"
|
||||||
|
|
||||||
@@ -386,12 +389,12 @@ Add multiple samples to a profile for better quality:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add first sample
|
# Add first sample
|
||||||
curl -X POST http://localhost:8000/profiles/abc-123/samples \
|
curl -X POST http://localhost:17493/profiles/abc-123/samples \
|
||||||
-F "file=@sample1.wav" \
|
-F "file=@sample1.wav" \
|
||||||
-F "reference_text=First sample"
|
-F "reference_text=First sample"
|
||||||
|
|
||||||
# Add second sample
|
# Add second sample
|
||||||
curl -X POST http://localhost:8000/profiles/abc-123/samples \
|
curl -X POST http://localhost:17493/profiles/abc-123/samples \
|
||||||
-F "file=@sample2.wav" \
|
-F "file=@sample2.wav" \
|
||||||
-F "reference_text=Second sample"
|
-F "reference_text=Second sample"
|
||||||
|
|
||||||
@@ -412,10 +415,10 @@ Models are lazy-loaded and can be manually unloaded:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Unload TTS model
|
# Unload TTS model
|
||||||
curl -X POST http://localhost:8000/models/unload
|
curl -X POST http://localhost:17493/models/unload
|
||||||
|
|
||||||
# Load specific model size
|
# Load specific model size
|
||||||
curl -X POST "http://localhost:8000/models/load?model_size=0.6B"
|
curl -X POST "http://localhost:17493/models/load?model_size=0.6B"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
# Backend package
|
# Backend package
|
||||||
|
|
||||||
__version__ = "0.1.11"
|
__version__ = "0.1.13"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Backend abstraction layer for TTS and STT.
|
|||||||
Provides a unified interface for MLX and PyTorch backends.
|
Provides a unified interface for MLX and PyTorch backends.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
from typing import Protocol, Optional, Tuple, List
|
from typing import Protocol, Optional, Tuple, List
|
||||||
from typing_extensions import runtime_checkable
|
from typing_extensions import runtime_checkable
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -112,29 +113,73 @@ class STTBackend(Protocol):
|
|||||||
|
|
||||||
# Global backend instances
|
# Global backend instances
|
||||||
_tts_backend: Optional[TTSBackend] = None
|
_tts_backend: Optional[TTSBackend] = None
|
||||||
|
_tts_backends: dict[str, TTSBackend] = {}
|
||||||
|
_tts_backends_lock = threading.Lock()
|
||||||
_stt_backend: Optional[STTBackend] = None
|
_stt_backend: Optional[STTBackend] = None
|
||||||
|
|
||||||
|
# Supported TTS engines
|
||||||
|
TTS_ENGINES = {
|
||||||
|
"qwen": "Qwen TTS",
|
||||||
|
"luxtts": "LuxTTS",
|
||||||
|
"chatterbox": "Chatterbox TTS",
|
||||||
|
"chatterbox_turbo": "Chatterbox Turbo",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_tts_backend() -> TTSBackend:
|
def get_tts_backend() -> TTSBackend:
|
||||||
"""
|
"""
|
||||||
Get or create TTS backend instance based on platform.
|
Get or create the default (Qwen) TTS backend instance based on platform.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
TTS backend instance (MLX or PyTorch)
|
TTS backend instance (MLX or PyTorch)
|
||||||
"""
|
"""
|
||||||
global _tts_backend
|
return get_tts_backend_for_engine("qwen")
|
||||||
|
|
||||||
|
|
||||||
|
def get_tts_backend_for_engine(engine: str) -> TTSBackend:
|
||||||
|
"""
|
||||||
|
Get or create a TTS backend for the given engine.
|
||||||
|
|
||||||
if _tts_backend is None:
|
Args:
|
||||||
backend_type = get_backend_type()
|
engine: Engine name ("qwen" or "luxtts")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TTS backend instance
|
||||||
|
"""
|
||||||
|
global _tts_backends
|
||||||
|
|
||||||
|
# Fast path: check without lock
|
||||||
|
if engine in _tts_backends:
|
||||||
|
return _tts_backends[engine]
|
||||||
|
|
||||||
|
# Slow path: create with lock to avoid duplicate instantiation
|
||||||
|
with _tts_backends_lock:
|
||||||
|
# Double-check after acquiring lock
|
||||||
|
if engine in _tts_backends:
|
||||||
|
return _tts_backends[engine]
|
||||||
|
|
||||||
if backend_type == "mlx":
|
if engine == "qwen":
|
||||||
from .mlx_backend import MLXTTSBackend
|
backend_type = get_backend_type()
|
||||||
_tts_backend = MLXTTSBackend()
|
if backend_type == "mlx":
|
||||||
|
from .mlx_backend import MLXTTSBackend
|
||||||
|
backend = MLXTTSBackend()
|
||||||
|
else:
|
||||||
|
from .pytorch_backend import PyTorchTTSBackend
|
||||||
|
backend = PyTorchTTSBackend()
|
||||||
|
elif engine == "luxtts":
|
||||||
|
from .luxtts_backend import LuxTTSBackend
|
||||||
|
backend = LuxTTSBackend()
|
||||||
|
elif engine == "chatterbox":
|
||||||
|
from .chatterbox_backend import ChatterboxTTSBackend
|
||||||
|
backend = ChatterboxTTSBackend()
|
||||||
|
elif engine == "chatterbox_turbo":
|
||||||
|
from .chatterbox_turbo_backend import ChatterboxTurboTTSBackend
|
||||||
|
backend = ChatterboxTurboTTSBackend()
|
||||||
else:
|
else:
|
||||||
from .pytorch_backend import PyTorchTTSBackend
|
raise ValueError(f"Unknown TTS engine: {engine}. Supported: {list(TTS_ENGINES.keys())}")
|
||||||
_tts_backend = PyTorchTTSBackend()
|
|
||||||
|
_tts_backends[engine] = backend
|
||||||
return _tts_backend
|
return backend
|
||||||
|
|
||||||
|
|
||||||
def get_stt_backend() -> STTBackend:
|
def get_stt_backend() -> STTBackend:
|
||||||
@@ -161,6 +206,7 @@ def get_stt_backend() -> STTBackend:
|
|||||||
|
|
||||||
def reset_backends():
|
def reset_backends():
|
||||||
"""Reset backend instances (useful for testing)."""
|
"""Reset backend instances (useful for testing)."""
|
||||||
global _tts_backend, _stt_backend
|
global _tts_backend, _tts_backends, _stt_backend
|
||||||
_tts_backend = None
|
_tts_backend = None
|
||||||
|
_tts_backends.clear()
|
||||||
_stt_backend = None
|
_stt_backend = None
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
"""
|
||||||
|
Chatterbox TTS backend implementation.
|
||||||
|
|
||||||
|
Wraps ChatterboxMultilingualTTS from chatterbox-tts for zero-shot
|
||||||
|
voice cloning. Supports 23 languages including Hebrew. Forces CPU
|
||||||
|
on macOS due to known MPS tensor issues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import platform
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import ClassVar, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from . import TTSBackend
|
||||||
|
from ..utils.audio import normalize_audio, load_audio
|
||||||
|
from ..utils.progress import get_progress_manager
|
||||||
|
from ..utils.tasks import get_task_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CHATTERBOX_HF_REPO = "ResembleAI/chatterbox"
|
||||||
|
|
||||||
|
# Files that must be present for the multilingual model
|
||||||
|
_MTL_WEIGHT_FILES = [
|
||||||
|
"t3_mtl23ls_v2.safetensors",
|
||||||
|
"s3gen.pt",
|
||||||
|
"ve.pt",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ChatterboxTTSBackend:
|
||||||
|
"""Chatterbox Multilingual TTS backend for voice cloning."""
|
||||||
|
|
||||||
|
# Class-level lock for torch.load monkey-patching
|
||||||
|
_load_lock: ClassVar[threading.Lock] = threading.Lock()
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.model = None
|
||||||
|
self.model_size = "default"
|
||||||
|
self._device = None
|
||||||
|
self._model_load_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def _get_device(self) -> str:
|
||||||
|
"""Get the best available device. Forces CPU on macOS (MPS issue)."""
|
||||||
|
if platform.system() == "Darwin":
|
||||||
|
return "cpu"
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
return "cuda"
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
def is_loaded(self) -> bool:
|
||||||
|
return self.model is not None
|
||||||
|
|
||||||
|
def _get_model_path(self, model_size: str = "default") -> str:
|
||||||
|
return CHATTERBOX_HF_REPO
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str = "default") -> bool:
|
||||||
|
"""Check if the Chatterbox multilingual model is cached locally."""
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / (
|
||||||
|
"models--" + CHATTERBOX_HF_REPO.replace("/", "--")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not repo_cache.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
blobs_dir = repo_cache / "blobs"
|
||||||
|
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for multilingual weight files
|
||||||
|
snapshots_dir = repo_cache / "snapshots"
|
||||||
|
if snapshots_dir.exists():
|
||||||
|
for fname in _MTL_WEIGHT_FILES:
|
||||||
|
if not any(snapshots_dir.rglob(fname)):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error checking Chatterbox cache: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def load_model(self, model_size: str = "default") -> None:
|
||||||
|
"""Load the Chatterbox multilingual model."""
|
||||||
|
if self.model is not None:
|
||||||
|
return
|
||||||
|
async with self._model_load_lock:
|
||||||
|
if self.model is not None:
|
||||||
|
return
|
||||||
|
await asyncio.to_thread(self._load_model_sync)
|
||||||
|
|
||||||
|
def _load_model_sync(self):
|
||||||
|
"""Synchronous model loading."""
|
||||||
|
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||||
|
|
||||||
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
|
model_name = "chatterbox-tts"
|
||||||
|
|
||||||
|
is_cached = self._is_model_cached()
|
||||||
|
|
||||||
|
# Set up HF progress tracking (intercepts tqdm for file-level progress)
|
||||||
|
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||||
|
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||||
|
tracker_context = tracker.patch_download()
|
||||||
|
tracker_context.__enter__()
|
||||||
|
|
||||||
|
if not is_cached:
|
||||||
|
task_manager.start_download(model_name)
|
||||||
|
progress_manager.update_progress(
|
||||||
|
model_name=model_name,
|
||||||
|
current=0,
|
||||||
|
total=0,
|
||||||
|
filename="Connecting to HuggingFace...",
|
||||||
|
status="downloading",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
device = self._get_device()
|
||||||
|
self._device = device
|
||||||
|
|
||||||
|
logger.info(f"Loading Chatterbox Multilingual TTS on {device}...")
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from chatterbox.mtl_tts import ChatterboxMultilingualTTS
|
||||||
|
|
||||||
|
# Load into a local variable first, apply all patches, then
|
||||||
|
# assign to self.model. This avoids leaving a half-initialised
|
||||||
|
# model on self.model if any patch step raises an exception.
|
||||||
|
#
|
||||||
|
# Monkey-patch torch.load for CPU loading. The model's .pt files
|
||||||
|
# were saved on CUDA; from_pretrained() doesn't pass map_location
|
||||||
|
# so loading on CPU fails without this.
|
||||||
|
try:
|
||||||
|
if device == "cpu":
|
||||||
|
_orig_torch_load = torch.load
|
||||||
|
|
||||||
|
def _patched_load(*args, **kwargs):
|
||||||
|
kwargs.setdefault("map_location", "cpu")
|
||||||
|
return _orig_torch_load(*args, **kwargs)
|
||||||
|
|
||||||
|
with ChatterboxTTSBackend._load_lock:
|
||||||
|
torch.load = _patched_load
|
||||||
|
try:
|
||||||
|
model = ChatterboxMultilingualTTS.from_pretrained(
|
||||||
|
device=device,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
torch.load = _orig_torch_load
|
||||||
|
else:
|
||||||
|
model = ChatterboxMultilingualTTS.from_pretrained(
|
||||||
|
device=device,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
|
# Fix: transformers >= 4.36 defaults LlamaModel to sdpa attention
|
||||||
|
# which doesn't support output_attentions=True (needed by
|
||||||
|
# Chatterbox's AlignmentStreamAnalyzer). Force eager attention.
|
||||||
|
t3_tfmr = model.t3.tfmr
|
||||||
|
if hasattr(t3_tfmr, "config") and hasattr(
|
||||||
|
t3_tfmr.config, "_attn_implementation"
|
||||||
|
):
|
||||||
|
t3_tfmr.config._attn_implementation = "eager"
|
||||||
|
for layer in getattr(t3_tfmr, "layers", []):
|
||||||
|
if hasattr(layer, "self_attn"):
|
||||||
|
layer.self_attn._attn_implementation = "eager"
|
||||||
|
|
||||||
|
if not is_cached:
|
||||||
|
progress_manager.mark_complete(model_name)
|
||||||
|
task_manager.complete_download(model_name)
|
||||||
|
|
||||||
|
# Patch float64 → float32 dtype mismatches in upstream chatterbox.
|
||||||
|
# librosa.load returns float64 numpy; multiple upstream code paths
|
||||||
|
# convert it to a torch tensor via torch.from_numpy() without
|
||||||
|
# casting, then matmul it against float32 model weights.
|
||||||
|
import types
|
||||||
|
|
||||||
|
# Patch S3Tokenizer (used by s3gen.tokenizer)
|
||||||
|
_tokzr = model.s3gen.tokenizer
|
||||||
|
_orig_log_mel = _tokzr.log_mel_spectrogram.__func__
|
||||||
|
|
||||||
|
def _f32_log_mel(self_tokzr, audio, padding=0):
|
||||||
|
import torch as _torch
|
||||||
|
if _torch.is_tensor(audio):
|
||||||
|
audio = audio.float()
|
||||||
|
return _orig_log_mel(self_tokzr, audio, padding)
|
||||||
|
|
||||||
|
_tokzr.log_mel_spectrogram = types.MethodType(_f32_log_mel, _tokzr)
|
||||||
|
|
||||||
|
# Patch VoiceEncoder
|
||||||
|
_ve = model.ve
|
||||||
|
_orig_ve_forward = _ve.forward.__func__
|
||||||
|
|
||||||
|
def _f32_ve_forward(self_ve, mels):
|
||||||
|
return _orig_ve_forward(self_ve, mels.float())
|
||||||
|
|
||||||
|
_ve.forward = types.MethodType(_f32_ve_forward, _ve)
|
||||||
|
|
||||||
|
# All patches applied successfully — publish the model
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
logger.info("Chatterbox Multilingual TTS loaded successfully")
|
||||||
|
|
||||||
|
except ImportError as e:
|
||||||
|
logger.error(
|
||||||
|
"chatterbox-tts package not found. "
|
||||||
|
"Install with: pip install chatterbox-tts"
|
||||||
|
)
|
||||||
|
if not is_cached:
|
||||||
|
progress_manager.mark_error(model_name, str(e))
|
||||||
|
task_manager.error_download(model_name, str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load Chatterbox: {e}")
|
||||||
|
if not is_cached:
|
||||||
|
progress_manager.mark_error(model_name, str(e))
|
||||||
|
task_manager.error_download(model_name, str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
def unload_model(self) -> None:
|
||||||
|
"""Unload model to free memory."""
|
||||||
|
if self.model is not None:
|
||||||
|
device = self._device
|
||||||
|
del self.model
|
||||||
|
self.model = None
|
||||||
|
self._device = None
|
||||||
|
if device == "cuda":
|
||||||
|
import torch
|
||||||
|
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
logger.info("Chatterbox unloaded")
|
||||||
|
|
||||||
|
async def create_voice_prompt(
|
||||||
|
self,
|
||||||
|
audio_path: str,
|
||||||
|
reference_text: str,
|
||||||
|
use_cache: bool = True,
|
||||||
|
) -> Tuple[dict, bool]:
|
||||||
|
"""
|
||||||
|
Create voice prompt from reference audio.
|
||||||
|
|
||||||
|
Chatterbox processes reference audio at generation time, so the
|
||||||
|
prompt just stores the file path. The actual audio is loaded by
|
||||||
|
model.generate() via audio_prompt_path.
|
||||||
|
"""
|
||||||
|
voice_prompt = {
|
||||||
|
"ref_audio": str(audio_path),
|
||||||
|
"ref_text": reference_text,
|
||||||
|
}
|
||||||
|
return voice_prompt, False
|
||||||
|
|
||||||
|
async def combine_voice_prompts(
|
||||||
|
self,
|
||||||
|
audio_paths: List[str],
|
||||||
|
reference_texts: List[str],
|
||||||
|
) -> Tuple[np.ndarray, str]:
|
||||||
|
"""Combine multiple reference samples."""
|
||||||
|
combined_audio = []
|
||||||
|
for path in audio_paths:
|
||||||
|
audio, _sr = load_audio(path)
|
||||||
|
audio = normalize_audio(audio)
|
||||||
|
combined_audio.append(audio)
|
||||||
|
|
||||||
|
mixed = np.concatenate(combined_audio)
|
||||||
|
mixed = normalize_audio(mixed)
|
||||||
|
combined_text = " ".join(reference_texts)
|
||||||
|
return mixed, combined_text
|
||||||
|
|
||||||
|
# Per-language generation defaults. Lower temp + higher cfg = clearer speech.
|
||||||
|
_LANG_DEFAULTS: ClassVar[dict] = {
|
||||||
|
"he": {
|
||||||
|
"exaggeration": 0.4,
|
||||||
|
"cfg_weight": 0.7,
|
||||||
|
"temperature": 0.65,
|
||||||
|
"repetition_penalty": 2.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_GLOBAL_DEFAULTS: ClassVar[dict] = {
|
||||||
|
"exaggeration": 0.5,
|
||||||
|
"cfg_weight": 0.5,
|
||||||
|
"temperature": 0.8,
|
||||||
|
"repetition_penalty": 2.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
voice_prompt: dict,
|
||||||
|
language: str = "en",
|
||||||
|
seed: Optional[int] = None,
|
||||||
|
instruct: Optional[str] = None,
|
||||||
|
) -> Tuple[np.ndarray, int]:
|
||||||
|
"""
|
||||||
|
Generate audio using Chatterbox Multilingual TTS.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to synthesize
|
||||||
|
voice_prompt: Dict with ref_audio path
|
||||||
|
language: BCP-47 language code
|
||||||
|
seed: Random seed for reproducibility
|
||||||
|
instruct: Unused (protocol compatibility)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (audio_array, sample_rate)
|
||||||
|
"""
|
||||||
|
await self.load_model()
|
||||||
|
|
||||||
|
ref_audio = voice_prompt.get("ref_audio")
|
||||||
|
if ref_audio and not Path(ref_audio).exists():
|
||||||
|
logger.warning(f"Reference audio not found: {ref_audio}")
|
||||||
|
ref_audio = None
|
||||||
|
|
||||||
|
# Merge language-specific defaults with global defaults
|
||||||
|
lang_defaults = self._LANG_DEFAULTS.get(language, self._GLOBAL_DEFAULTS)
|
||||||
|
|
||||||
|
def _generate_sync():
|
||||||
|
import torch
|
||||||
|
|
||||||
|
if seed is not None:
|
||||||
|
torch.manual_seed(seed)
|
||||||
|
|
||||||
|
logger.info(f"[Chatterbox] Generating: lang={language}")
|
||||||
|
|
||||||
|
wav = self.model.generate(
|
||||||
|
text,
|
||||||
|
language_id=language,
|
||||||
|
audio_prompt_path=ref_audio,
|
||||||
|
exaggeration=lang_defaults["exaggeration"],
|
||||||
|
cfg_weight=lang_defaults["cfg_weight"],
|
||||||
|
temperature=lang_defaults["temperature"],
|
||||||
|
repetition_penalty=lang_defaults["repetition_penalty"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert tensor -> numpy
|
||||||
|
if isinstance(wav, torch.Tensor):
|
||||||
|
audio = wav.squeeze().cpu().numpy().astype(np.float32)
|
||||||
|
else:
|
||||||
|
audio = np.asarray(wav, dtype=np.float32)
|
||||||
|
|
||||||
|
sample_rate = (
|
||||||
|
getattr(self.model, "sr", None)
|
||||||
|
or getattr(self.model, "sample_rate", 24000)
|
||||||
|
)
|
||||||
|
|
||||||
|
return audio, sample_rate
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_generate_sync)
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"""
|
||||||
|
Chatterbox Turbo TTS backend implementation.
|
||||||
|
|
||||||
|
Wraps ChatterboxTurboTTS from chatterbox-tts for fast, English-only
|
||||||
|
voice cloning with paralinguistic tag support ([laugh], [cough], etc.).
|
||||||
|
Forces CPU on macOS due to known MPS tensor issues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import platform
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import ClassVar, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from . import TTSBackend
|
||||||
|
from ..utils.audio import normalize_audio, load_audio
|
||||||
|
from ..utils.progress import get_progress_manager
|
||||||
|
from ..utils.tasks import get_task_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CHATTERBOX_TURBO_HF_REPO = "ResembleAI/chatterbox-turbo"
|
||||||
|
|
||||||
|
# Files that must be present for the turbo model
|
||||||
|
_TURBO_WEIGHT_FILES = [
|
||||||
|
"t3_turbo_v1.safetensors",
|
||||||
|
"s3gen_meanflow.safetensors",
|
||||||
|
"ve.safetensors",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ChatterboxTurboTTSBackend:
|
||||||
|
"""Chatterbox Turbo TTS backend — fast, English-only, with paralinguistic tags."""
|
||||||
|
|
||||||
|
# Class-level lock for torch.load monkey-patching
|
||||||
|
_load_lock: ClassVar[threading.Lock] = threading.Lock()
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.model = None
|
||||||
|
self.model_size = "default"
|
||||||
|
self._device = None
|
||||||
|
self._model_load_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def _get_device(self) -> str:
|
||||||
|
"""Get the best available device. Forces CPU on macOS (MPS issue)."""
|
||||||
|
if platform.system() == "Darwin":
|
||||||
|
return "cpu"
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
return "cuda"
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
def is_loaded(self) -> bool:
|
||||||
|
return self.model is not None
|
||||||
|
|
||||||
|
def _get_model_path(self, model_size: str = "default") -> str:
|
||||||
|
return CHATTERBOX_TURBO_HF_REPO
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str = "default") -> bool:
|
||||||
|
"""Check if the Chatterbox Turbo model is cached locally."""
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / (
|
||||||
|
"models--" + CHATTERBOX_TURBO_HF_REPO.replace("/", "--")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not repo_cache.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
blobs_dir = repo_cache / "blobs"
|
||||||
|
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for turbo weight files
|
||||||
|
snapshots_dir = repo_cache / "snapshots"
|
||||||
|
if snapshots_dir.exists():
|
||||||
|
for fname in _TURBO_WEIGHT_FILES:
|
||||||
|
if not any(snapshots_dir.rglob(fname)):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error checking Chatterbox Turbo cache: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def load_model(self, model_size: str = "default") -> None:
|
||||||
|
"""Load the Chatterbox Turbo model."""
|
||||||
|
if self.model is not None:
|
||||||
|
return
|
||||||
|
async with self._model_load_lock:
|
||||||
|
if self.model is not None:
|
||||||
|
return
|
||||||
|
await asyncio.to_thread(self._load_model_sync)
|
||||||
|
|
||||||
|
def _load_model_sync(self):
|
||||||
|
"""Synchronous model loading."""
|
||||||
|
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||||
|
|
||||||
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
|
model_name = "chatterbox-turbo"
|
||||||
|
|
||||||
|
is_cached = self._is_model_cached()
|
||||||
|
|
||||||
|
# Set up HF progress tracking (intercepts tqdm for file-level progress)
|
||||||
|
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||||
|
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||||
|
tracker_context = tracker.patch_download()
|
||||||
|
tracker_context.__enter__()
|
||||||
|
|
||||||
|
if not is_cached:
|
||||||
|
task_manager.start_download(model_name)
|
||||||
|
progress_manager.update_progress(
|
||||||
|
model_name=model_name,
|
||||||
|
current=0,
|
||||||
|
total=0,
|
||||||
|
filename="Connecting to HuggingFace...",
|
||||||
|
status="downloading",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
device = self._get_device()
|
||||||
|
self._device = device
|
||||||
|
|
||||||
|
logger.info(f"Loading Chatterbox Turbo TTS on {device}...")
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
|
from chatterbox.tts_turbo import ChatterboxTurboTTS
|
||||||
|
|
||||||
|
# Download model files ourselves so we can pass token=None
|
||||||
|
# (upstream from_pretrained passes token=True which requires
|
||||||
|
# a stored HF token even though the repo is public).
|
||||||
|
try:
|
||||||
|
local_path = snapshot_download(
|
||||||
|
repo_id=CHATTERBOX_TURBO_HF_REPO,
|
||||||
|
token=None,
|
||||||
|
allow_patterns=[
|
||||||
|
"*.safetensors", "*.json", "*.txt", "*.pt", "*.model",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
|
# Monkey-patch torch.load for CPU loading. The model's .pt files
|
||||||
|
# were saved on CUDA; from_local() doesn't pass map_location
|
||||||
|
# so loading on CPU fails without this.
|
||||||
|
# Load into a local var, apply patches, then publish to
|
||||||
|
# self.model so a failed patch doesn't leave us half-initialised.
|
||||||
|
if device == "cpu":
|
||||||
|
_orig_torch_load = torch.load
|
||||||
|
|
||||||
|
def _patched_load(*args, **kwargs):
|
||||||
|
kwargs.setdefault("map_location", "cpu")
|
||||||
|
return _orig_torch_load(*args, **kwargs)
|
||||||
|
|
||||||
|
with ChatterboxTurboTTSBackend._load_lock:
|
||||||
|
torch.load = _patched_load
|
||||||
|
try:
|
||||||
|
model = ChatterboxTurboTTS.from_local(
|
||||||
|
local_path, device,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
torch.load = _orig_torch_load
|
||||||
|
else:
|
||||||
|
model = ChatterboxTurboTTS.from_local(
|
||||||
|
local_path, device,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_cached:
|
||||||
|
progress_manager.mark_complete(model_name)
|
||||||
|
task_manager.complete_download(model_name)
|
||||||
|
|
||||||
|
# Patch float64 → float32 dtype mismatches in upstream chatterbox.
|
||||||
|
# librosa.load returns float64 numpy; multiple upstream code paths
|
||||||
|
# convert it to a torch tensor via torch.from_numpy() without
|
||||||
|
# casting, then matmul it against float32 model weights.
|
||||||
|
# We patch the two known entry points:
|
||||||
|
#
|
||||||
|
# 1. S3Tokenizer.log_mel_spectrogram — the audio tensor from
|
||||||
|
# librosa hits _mel_filters (float32) in a matmul.
|
||||||
|
# 2. VoiceEncoder.forward — float64 mel spectrograms hit the
|
||||||
|
# float32 LSTM weights.
|
||||||
|
import types
|
||||||
|
|
||||||
|
# Patch S3Tokenizer (used by s3gen.tokenizer)
|
||||||
|
_tokzr = model.s3gen.tokenizer
|
||||||
|
_orig_log_mel = _tokzr.log_mel_spectrogram.__func__
|
||||||
|
|
||||||
|
def _f32_log_mel(self_tokzr, audio, padding=0):
|
||||||
|
import torch as _torch
|
||||||
|
if _torch.is_tensor(audio):
|
||||||
|
audio = audio.float()
|
||||||
|
return _orig_log_mel(self_tokzr, audio, padding)
|
||||||
|
|
||||||
|
_tokzr.log_mel_spectrogram = types.MethodType(_f32_log_mel, _tokzr)
|
||||||
|
|
||||||
|
# Patch VoiceEncoder
|
||||||
|
_ve = model.ve
|
||||||
|
_orig_ve_forward = _ve.forward.__func__
|
||||||
|
|
||||||
|
def _f32_ve_forward(self_ve, mels):
|
||||||
|
return _orig_ve_forward(self_ve, mels.float())
|
||||||
|
|
||||||
|
_ve.forward = types.MethodType(_f32_ve_forward, _ve)
|
||||||
|
|
||||||
|
# Only publish after all patches succeed
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
logger.info("Chatterbox Turbo TTS loaded successfully")
|
||||||
|
|
||||||
|
except ImportError as e:
|
||||||
|
logger.error(
|
||||||
|
"chatterbox-tts package not found. "
|
||||||
|
"Install with: pip install chatterbox-tts"
|
||||||
|
)
|
||||||
|
if not is_cached:
|
||||||
|
progress_manager.mark_error(model_name, str(e))
|
||||||
|
task_manager.error_download(model_name, str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load Chatterbox Turbo: {e}")
|
||||||
|
if not is_cached:
|
||||||
|
progress_manager.mark_error(model_name, str(e))
|
||||||
|
task_manager.error_download(model_name, str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
def unload_model(self) -> None:
|
||||||
|
"""Unload model to free memory."""
|
||||||
|
if self.model is not None:
|
||||||
|
device = self._device
|
||||||
|
del self.model
|
||||||
|
self.model = None
|
||||||
|
self._device = None
|
||||||
|
if device == "cuda":
|
||||||
|
import torch
|
||||||
|
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
logger.info("Chatterbox Turbo unloaded")
|
||||||
|
|
||||||
|
async def create_voice_prompt(
|
||||||
|
self,
|
||||||
|
audio_path: str,
|
||||||
|
reference_text: str,
|
||||||
|
use_cache: bool = True,
|
||||||
|
) -> Tuple[dict, bool]:
|
||||||
|
"""
|
||||||
|
Create voice prompt from reference audio.
|
||||||
|
|
||||||
|
Chatterbox Turbo processes reference audio at generation time, so the
|
||||||
|
prompt just stores the file path.
|
||||||
|
"""
|
||||||
|
voice_prompt = {
|
||||||
|
"ref_audio": str(audio_path),
|
||||||
|
"ref_text": reference_text,
|
||||||
|
}
|
||||||
|
return voice_prompt, False
|
||||||
|
|
||||||
|
async def combine_voice_prompts(
|
||||||
|
self,
|
||||||
|
audio_paths: List[str],
|
||||||
|
reference_texts: List[str],
|
||||||
|
) -> Tuple[np.ndarray, str]:
|
||||||
|
"""Combine multiple reference samples."""
|
||||||
|
combined_audio = []
|
||||||
|
for path in audio_paths:
|
||||||
|
audio, _sr = load_audio(path)
|
||||||
|
audio = normalize_audio(audio)
|
||||||
|
combined_audio.append(audio)
|
||||||
|
|
||||||
|
mixed = np.concatenate(combined_audio)
|
||||||
|
mixed = normalize_audio(mixed)
|
||||||
|
combined_text = " ".join(reference_texts)
|
||||||
|
return mixed, combined_text
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
voice_prompt: dict,
|
||||||
|
language: str = "en",
|
||||||
|
seed: Optional[int] = None,
|
||||||
|
instruct: Optional[str] = None,
|
||||||
|
) -> Tuple[np.ndarray, int]:
|
||||||
|
"""
|
||||||
|
Generate audio using Chatterbox Turbo TTS.
|
||||||
|
|
||||||
|
Supports paralinguistic tags in text: [laugh], [cough], [chuckle], etc.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to synthesize (may include paralinguistic tags)
|
||||||
|
voice_prompt: Dict with ref_audio path
|
||||||
|
language: Ignored (Turbo is English-only)
|
||||||
|
seed: Random seed for reproducibility
|
||||||
|
instruct: Unused (protocol compatibility)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (audio_array, sample_rate)
|
||||||
|
"""
|
||||||
|
await self.load_model()
|
||||||
|
|
||||||
|
ref_audio = voice_prompt.get("ref_audio")
|
||||||
|
if ref_audio and not Path(ref_audio).exists():
|
||||||
|
logger.warning(f"Reference audio not found: {ref_audio}")
|
||||||
|
ref_audio = None
|
||||||
|
|
||||||
|
def _generate_sync():
|
||||||
|
import torch
|
||||||
|
|
||||||
|
if seed is not None:
|
||||||
|
torch.manual_seed(seed)
|
||||||
|
|
||||||
|
logger.info("[Chatterbox Turbo] Generating (English)")
|
||||||
|
|
||||||
|
wav = self.model.generate(
|
||||||
|
text,
|
||||||
|
audio_prompt_path=ref_audio,
|
||||||
|
temperature=0.8,
|
||||||
|
top_k=1000,
|
||||||
|
top_p=0.95,
|
||||||
|
repetition_penalty=1.2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert tensor -> numpy
|
||||||
|
if isinstance(wav, torch.Tensor):
|
||||||
|
audio = wav.squeeze().cpu().numpy().astype(np.float32)
|
||||||
|
else:
|
||||||
|
audio = np.asarray(wav, dtype=np.float32)
|
||||||
|
|
||||||
|
sample_rate = (
|
||||||
|
getattr(self.model, "sr", None)
|
||||||
|
or getattr(self.model, "sample_rate", 24000)
|
||||||
|
)
|
||||||
|
|
||||||
|
return audio, sample_rate
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_generate_sync)
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""
|
||||||
|
LuxTTS backend implementation.
|
||||||
|
|
||||||
|
Wraps the LuxTTS (ZipVoice) model for zero-shot voice cloning.
|
||||||
|
~1GB VRAM, 48kHz output, 150x realtime on CPU.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from . import TTSBackend
|
||||||
|
from ..utils.audio import normalize_audio, load_audio
|
||||||
|
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
||||||
|
from ..utils.progress import get_progress_manager
|
||||||
|
from ..utils.tasks import get_task_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# HuggingFace repo for model weight detection
|
||||||
|
LUXTTS_HF_REPO = "YatharthS/LuxTTS"
|
||||||
|
|
||||||
|
|
||||||
|
class LuxTTSBackend:
|
||||||
|
"""LuxTTS backend for zero-shot voice cloning."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.model = None
|
||||||
|
self.model_size = "default" # LuxTTS has only one model size
|
||||||
|
self._device = None
|
||||||
|
|
||||||
|
def _get_device(self) -> str:
|
||||||
|
"""Get the best available device."""
|
||||||
|
import torch
|
||||||
|
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
return "cuda"
|
||||||
|
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
|
return "mps"
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
def is_loaded(self) -> bool:
|
||||||
|
return self.model is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device(self) -> str:
|
||||||
|
if self._device is None:
|
||||||
|
self._device = self._get_device()
|
||||||
|
return self._device
|
||||||
|
|
||||||
|
def _get_model_path(self, model_size: str) -> str:
|
||||||
|
return LUXTTS_HF_REPO
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str = "default") -> bool:
|
||||||
|
"""Check if LuxTTS model weights are cached locally."""
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
|
||||||
|
repo_cache = (
|
||||||
|
Path(hf_constants.HF_HUB_CACHE)
|
||||||
|
/ ("models--" + LUXTTS_HF_REPO.replace("/", "--"))
|
||||||
|
)
|
||||||
|
|
||||||
|
if not repo_cache.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
blobs_dir = repo_cache / "blobs"
|
||||||
|
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||||
|
return False
|
||||||
|
|
||||||
|
snapshots_dir = repo_cache / "snapshots"
|
||||||
|
if snapshots_dir.exists():
|
||||||
|
has_weights = any(snapshots_dir.rglob("*.pt")) or any(
|
||||||
|
snapshots_dir.rglob("*.safetensors")
|
||||||
|
) or any(snapshots_dir.rglob("*.onnx")) or any(
|
||||||
|
snapshots_dir.rglob("*.bin")
|
||||||
|
)
|
||||||
|
return has_weights
|
||||||
|
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error checking LuxTTS cache: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def load_model(self, model_size: str = "default") -> None:
|
||||||
|
"""Load the LuxTTS model."""
|
||||||
|
if self.model is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.to_thread(self._load_model_sync)
|
||||||
|
|
||||||
|
def _load_model_sync(self):
|
||||||
|
"""Synchronous model loading."""
|
||||||
|
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||||
|
|
||||||
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
|
model_name = "luxtts"
|
||||||
|
|
||||||
|
is_cached = self._is_model_cached()
|
||||||
|
|
||||||
|
# Set up HF progress tracking (intercepts tqdm for file-level progress)
|
||||||
|
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||||
|
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||||
|
tracker_context = tracker.patch_download()
|
||||||
|
tracker_context.__enter__()
|
||||||
|
|
||||||
|
if not is_cached:
|
||||||
|
task_manager.start_download(model_name)
|
||||||
|
progress_manager.update_progress(
|
||||||
|
model_name=model_name,
|
||||||
|
current=0,
|
||||||
|
total=0,
|
||||||
|
filename="Connecting to HuggingFace...",
|
||||||
|
status="downloading",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from zipvoice.luxvoice import LuxTTS
|
||||||
|
|
||||||
|
device = self.device
|
||||||
|
logger.info(f"Loading LuxTTS on {device}...")
|
||||||
|
|
||||||
|
# LuxTTS constructor downloads model and loads everything
|
||||||
|
try:
|
||||||
|
if device == "cpu":
|
||||||
|
import os
|
||||||
|
threads = os.cpu_count() or 4
|
||||||
|
self.model = LuxTTS(
|
||||||
|
model_path=LUXTTS_HF_REPO,
|
||||||
|
device="cpu",
|
||||||
|
threads=min(threads, 8),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.model = LuxTTS(
|
||||||
|
model_path=LUXTTS_HF_REPO,
|
||||||
|
device=device,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
|
if not is_cached:
|
||||||
|
progress_manager.mark_complete(model_name)
|
||||||
|
task_manager.complete_download(model_name)
|
||||||
|
|
||||||
|
logger.info("LuxTTS loaded successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load LuxTTS: {e}")
|
||||||
|
if not is_cached:
|
||||||
|
progress_manager.mark_error(model_name, str(e))
|
||||||
|
task_manager.error_download(model_name, str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
def unload_model(self) -> None:
|
||||||
|
"""Unload model to free memory."""
|
||||||
|
if self.model is not None:
|
||||||
|
del self.model
|
||||||
|
self.model = None
|
||||||
|
|
||||||
|
import torch
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
|
||||||
|
logger.info("LuxTTS unloaded")
|
||||||
|
|
||||||
|
async def create_voice_prompt(
|
||||||
|
self,
|
||||||
|
audio_path: str,
|
||||||
|
reference_text: str,
|
||||||
|
use_cache: bool = True,
|
||||||
|
) -> Tuple[dict, bool]:
|
||||||
|
"""
|
||||||
|
Create voice prompt from reference audio.
|
||||||
|
|
||||||
|
LuxTTS uses its own encode_prompt() which runs Whisper ASR internally
|
||||||
|
to transcribe the reference. The reference_text parameter is not used
|
||||||
|
by LuxTTS itself, but we include it in the cache key for consistency.
|
||||||
|
"""
|
||||||
|
await self.load_model()
|
||||||
|
|
||||||
|
# Compute cache key once for both lookup and storage
|
||||||
|
cache_key = ("luxtts_" + get_cache_key(audio_path, reference_text)) if use_cache else None
|
||||||
|
|
||||||
|
if cache_key:
|
||||||
|
cached = get_cached_voice_prompt(cache_key)
|
||||||
|
if cached is not None and isinstance(cached, dict):
|
||||||
|
return cached, True
|
||||||
|
|
||||||
|
def _encode_sync():
|
||||||
|
return self.model.encode_prompt(
|
||||||
|
prompt_audio=str(audio_path),
|
||||||
|
duration=5,
|
||||||
|
rms=0.01,
|
||||||
|
)
|
||||||
|
|
||||||
|
encoded = await asyncio.to_thread(_encode_sync)
|
||||||
|
|
||||||
|
if cache_key:
|
||||||
|
cache_voice_prompt(cache_key, encoded)
|
||||||
|
|
||||||
|
return encoded, False
|
||||||
|
|
||||||
|
async def combine_voice_prompts(
|
||||||
|
self,
|
||||||
|
audio_paths: List[str],
|
||||||
|
reference_texts: List[str],
|
||||||
|
) -> Tuple[np.ndarray, str]:
|
||||||
|
"""
|
||||||
|
Combine multiple reference samples.
|
||||||
|
|
||||||
|
LuxTTS doesn't have native multi-prompt support, so we concatenate
|
||||||
|
the audio and let encode_prompt handle the combined clip.
|
||||||
|
"""
|
||||||
|
combined_audio = []
|
||||||
|
for path in audio_paths:
|
||||||
|
audio, _sr = load_audio(path, sample_rate=24000)
|
||||||
|
audio = normalize_audio(audio)
|
||||||
|
combined_audio.append(audio)
|
||||||
|
|
||||||
|
mixed = np.concatenate(combined_audio)
|
||||||
|
mixed = normalize_audio(mixed)
|
||||||
|
combined_text = " ".join(reference_texts)
|
||||||
|
|
||||||
|
return mixed, combined_text
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
voice_prompt: dict,
|
||||||
|
language: str = "en",
|
||||||
|
seed: Optional[int] = None,
|
||||||
|
instruct: Optional[str] = None,
|
||||||
|
) -> Tuple[np.ndarray, int]:
|
||||||
|
"""
|
||||||
|
Generate audio from text using LuxTTS.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to synthesize
|
||||||
|
voice_prompt: Encoded prompt dict from encode_prompt()
|
||||||
|
language: Language code (LuxTTS is English-focused)
|
||||||
|
seed: Random seed for reproducibility
|
||||||
|
instruct: Not supported by LuxTTS (ignored)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (audio_array, sample_rate)
|
||||||
|
"""
|
||||||
|
await self.load_model()
|
||||||
|
|
||||||
|
def _generate_sync():
|
||||||
|
import torch
|
||||||
|
|
||||||
|
if seed is not None:
|
||||||
|
torch.manual_seed(seed)
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
torch.cuda.manual_seed(seed)
|
||||||
|
|
||||||
|
wav = self.model.generate_speech(
|
||||||
|
text=text,
|
||||||
|
encode_dict=voice_prompt,
|
||||||
|
num_steps=4,
|
||||||
|
guidance_scale=3.0,
|
||||||
|
t_shift=0.5,
|
||||||
|
speed=1.0,
|
||||||
|
return_smooth=False, # 48kHz output
|
||||||
|
)
|
||||||
|
|
||||||
|
# LuxTTS returns a tensor (may be on GPU/MPS), move to CPU first
|
||||||
|
audio = wav.detach().cpu().numpy().squeeze()
|
||||||
|
return audio, 48000
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_generate_sync)
|
||||||
+199
-55
@@ -5,8 +5,15 @@ MLX backend implementation for TTS and STT using mlx-audio.
|
|||||||
from typing import Optional, List, Tuple
|
from typing import Optional, List, Tuple
|
||||||
import asyncio
|
import asyncio
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# PATCH: Import and apply offline patch BEFORE any huggingface_hub usage
|
||||||
|
# This prevents mlx_audio from making network requests when models are cached
|
||||||
|
from ..utils.hf_offline_patch import patch_huggingface_hub_offline, ensure_original_qwen_config_cached
|
||||||
|
patch_huggingface_hub_offline()
|
||||||
|
ensure_original_qwen_config_cached()
|
||||||
|
|
||||||
from . import TTSBackend, STTBackend
|
from . import TTSBackend, STTBackend
|
||||||
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
||||||
from ..utils.audio import normalize_audio, load_audio
|
from ..utils.audio import normalize_audio, load_audio
|
||||||
@@ -14,6 +21,12 @@ from ..utils.progress import get_progress_manager
|
|||||||
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||||
from ..utils.tasks import get_task_manager
|
from ..utils.tasks import get_task_manager
|
||||||
|
|
||||||
|
LANGUAGE_CODE_TO_NAME = {
|
||||||
|
"zh": "chinese", "en": "english", "ja": "japanese", "ko": "korean",
|
||||||
|
"de": "german", "fr": "french", "ru": "russian", "pt": "portuguese",
|
||||||
|
"es": "spanish", "it": "italian",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class MLXTTSBackend:
|
class MLXTTSBackend:
|
||||||
"""MLX-based TTS backend using mlx-audio."""
|
"""MLX-based TTS backend using mlx-audio."""
|
||||||
@@ -52,6 +65,47 @@ class MLXTTSBackend:
|
|||||||
|
|
||||||
return hf_model_id
|
return hf_model_id
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the model is already cached locally AND fully downloaded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_size: Model size to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if model is fully cached, False if missing or incomplete
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
model_path = self._get_model_path(model_size)
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
|
||||||
|
|
||||||
|
if not repo_cache.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for .incomplete files - if any exist, download is still in progress
|
||||||
|
blobs_dir = repo_cache / "blobs"
|
||||||
|
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||||
|
print(f"[_is_model_cached] Found .incomplete files for {model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check that actual model weight files exist in snapshots
|
||||||
|
snapshots_dir = repo_cache / "snapshots"
|
||||||
|
if snapshots_dir.exists():
|
||||||
|
has_weights = (
|
||||||
|
any(snapshots_dir.rglob("*.safetensors")) or
|
||||||
|
any(snapshots_dir.rglob("*.bin")) or
|
||||||
|
any(snapshots_dir.rglob("*.npz"))
|
||||||
|
)
|
||||||
|
if not has_weights:
|
||||||
|
print(f"[_is_model_cached] No model weights found for {model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[_is_model_cached] Error checking cache for {model_size}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
async def load_model_async(self, model_size: Optional[str] = None):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the MLX TTS model.
|
Lazy load the MLX TTS model.
|
||||||
@@ -79,46 +133,83 @@ class MLXTTSBackend:
|
|||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
try:
|
try:
|
||||||
from mlx_audio.tts import load
|
# Get model path BEFORE importing mlx_audio
|
||||||
|
|
||||||
# Get model path
|
|
||||||
model_path = self._get_model_path(model_size)
|
model_path = self._get_model_path(model_size)
|
||||||
|
|
||||||
# Set up progress tracking
|
# Set up progress tracking
|
||||||
progress_manager = get_progress_manager()
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
model_name = f"qwen-tts-{model_size}"
|
model_name = f"qwen-tts-{model_size}"
|
||||||
|
|
||||||
# Start tracking download task
|
# Check if model is already cached
|
||||||
task_manager = get_task_manager()
|
is_cached = self._is_model_cached(model_size)
|
||||||
task_manager.start_download(model_name)
|
|
||||||
|
# Set up progress callback
|
||||||
|
# If cached: filter out non-download progress
|
||||||
|
# If not cached: report all progress (we're actually downloading)
|
||||||
|
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||||
|
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||||
|
|
||||||
print(f"Loading MLX TTS model {model_size}...")
|
print(f"Loading MLX TTS model {model_size}...")
|
||||||
|
|
||||||
# Initialize progress state
|
# Only track download progress if model is NOT cached
|
||||||
progress_manager.update_progress(
|
if not is_cached:
|
||||||
model_name=model_name,
|
# Start tracking download task
|
||||||
current=0,
|
task_manager.start_download(model_name)
|
||||||
total=1,
|
|
||||||
filename="",
|
# Initialize progress state so SSE endpoint has initial data to send
|
||||||
status="downloading",
|
# 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
|
# IMPORTANT: Patch tqdm BEFORE importing mlx_audio
|
||||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
# Otherwise mlx_audio caches reference to original tqdm
|
||||||
tracker = HFProgressTracker(progress_callback)
|
tracker_context = tracker.patch_download()
|
||||||
|
tracker_context.__enter__()
|
||||||
|
|
||||||
# Use progress tracker during download
|
# PATCH: Force offline mode when model is already cached
|
||||||
with tracker.patch_download():
|
# This prevents crashes when HuggingFace is unreachable
|
||||||
# Load MLX model (downloads automatically)
|
original_hf_hub_offline = os.environ.get("HF_HUB_OFFLINE")
|
||||||
|
if is_cached:
|
||||||
|
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||||
|
print(f"[PATCH] Model {model_size} is cached, forcing HF_HUB_OFFLINE=1 to avoid network requests")
|
||||||
|
|
||||||
|
# Import mlx_audio AFTER patching tqdm
|
||||||
|
from mlx_audio.tts import load
|
||||||
|
|
||||||
|
# Load MLX model (downloads automatically)
|
||||||
|
try:
|
||||||
self.model = load(model_path)
|
self.model = load(model_path)
|
||||||
|
except Exception as load_error:
|
||||||
|
# If offline mode failed, try with network enabled as fallback
|
||||||
|
if is_cached and "offline" in str(load_error).lower():
|
||||||
|
print(f"[PATCH] Offline load failed, trying with network: {load_error}")
|
||||||
|
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||||
|
self.model = load(model_path)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
# Exit the patch context
|
||||||
|
tracker_context.__exit__(None, None, None)
|
||||||
|
# Restore original HF_HUB_OFFLINE setting
|
||||||
|
if original_hf_hub_offline is not None:
|
||||||
|
os.environ["HF_HUB_OFFLINE"] = original_hf_hub_offline
|
||||||
|
else:
|
||||||
|
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||||
|
|
||||||
|
# Only mark download as complete if we were tracking it
|
||||||
|
if not is_cached:
|
||||||
|
progress_manager.mark_complete(model_name)
|
||||||
|
task_manager.complete_download(model_name)
|
||||||
|
|
||||||
self._current_model_size = model_size
|
self._current_model_size = model_size
|
||||||
self.model_size = model_size
|
self.model_size = model_size
|
||||||
|
|
||||||
# Mark as complete
|
|
||||||
progress_manager.mark_complete(model_name)
|
|
||||||
task_manager.complete_download(model_name)
|
|
||||||
|
|
||||||
print(f"MLX TTS model {model_size} loaded successfully")
|
print(f"MLX TTS model {model_size} loaded successfully")
|
||||||
|
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
@@ -258,7 +349,8 @@ class MLXTTSBackend:
|
|||||||
# MLX generate() returns a generator yielding GenerationResult objects
|
# MLX generate() returns a generator yielding GenerationResult objects
|
||||||
audio_chunks = []
|
audio_chunks = []
|
||||||
sample_rate = 24000
|
sample_rate = 24000
|
||||||
|
lang = LANGUAGE_CODE_TO_NAME.get(language, "auto")
|
||||||
|
|
||||||
# Set seed if provided (MLX uses numpy random)
|
# Set seed if provided (MLX uses numpy random)
|
||||||
if seed is not None:
|
if seed is not None:
|
||||||
import mlx.core as mx
|
import mlx.core as mx
|
||||||
@@ -286,23 +378,23 @@ class MLXTTSBackend:
|
|||||||
sig = inspect.signature(self.model.generate)
|
sig = inspect.signature(self.model.generate)
|
||||||
if "ref_audio" in sig.parameters:
|
if "ref_audio" in sig.parameters:
|
||||||
# Generate with voice cloning
|
# Generate with voice cloning
|
||||||
for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text):
|
for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text, lang_code=lang):
|
||||||
audio_chunks.append(np.array(result.audio))
|
audio_chunks.append(np.array(result.audio))
|
||||||
sample_rate = result.sample_rate
|
sample_rate = result.sample_rate
|
||||||
else:
|
else:
|
||||||
# Fallback: generate without voice cloning
|
# Fallback: generate without voice cloning
|
||||||
for result in self.model.generate(text):
|
for result in self.model.generate(text, lang_code=lang):
|
||||||
audio_chunks.append(np.array(result.audio))
|
audio_chunks.append(np.array(result.audio))
|
||||||
sample_rate = result.sample_rate
|
sample_rate = result.sample_rate
|
||||||
else:
|
else:
|
||||||
# No voice prompt, generate normally
|
# No voice prompt, generate normally
|
||||||
for result in self.model.generate(text):
|
for result in self.model.generate(text, lang_code=lang):
|
||||||
audio_chunks.append(np.array(result.audio))
|
audio_chunks.append(np.array(result.audio))
|
||||||
sample_rate = result.sample_rate
|
sample_rate = result.sample_rate
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If voice cloning fails, try without it
|
# If voice cloning fails, try without it
|
||||||
print(f"Warning: Voice cloning failed, generating without voice prompt: {e}")
|
print(f"Warning: Voice cloning failed, generating without voice prompt: {e}")
|
||||||
for result in self.model.generate(text):
|
for result in self.model.generate(text, lang_code=lang):
|
||||||
audio_chunks.append(np.array(result.audio))
|
audio_chunks.append(np.array(result.audio))
|
||||||
sample_rate = result.sample_rate
|
sample_rate = result.sample_rate
|
||||||
|
|
||||||
@@ -321,9 +413,17 @@ class MLXTTSBackend:
|
|||||||
return audio, sample_rate
|
return audio, sample_rate
|
||||||
|
|
||||||
|
|
||||||
|
WHISPER_HF_REPOS = {
|
||||||
|
"base": "openai/whisper-base",
|
||||||
|
"small": "openai/whisper-small",
|
||||||
|
"medium": "openai/whisper-medium",
|
||||||
|
"large": "openai/whisper-large-v3",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class MLXSTTBackend:
|
class MLXSTTBackend:
|
||||||
"""MLX-based STT backend using mlx-audio Whisper."""
|
"""MLX-based STT backend using mlx-audio Whisper."""
|
||||||
|
|
||||||
def __init__(self, model_size: str = "base"):
|
def __init__(self, model_size: str = "base"):
|
||||||
self.model = None
|
self.model = None
|
||||||
self.model_size = model_size
|
self.model_size = model_size
|
||||||
@@ -332,6 +432,47 @@ class MLXSTTBackend:
|
|||||||
"""Check if model is loaded."""
|
"""Check if model is loaded."""
|
||||||
return self.model is not None
|
return self.model is not None
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the Whisper model is already cached locally AND fully downloaded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_size: Model size to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if model is fully cached, False if missing or incomplete
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + hf_repo.replace("/", "--"))
|
||||||
|
|
||||||
|
if not repo_cache.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for .incomplete files - if any exist, download is still in progress
|
||||||
|
blobs_dir = repo_cache / "blobs"
|
||||||
|
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||||
|
print(f"[_is_model_cached] Found .incomplete files for whisper-{model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check that actual model weight files exist in snapshots
|
||||||
|
snapshots_dir = repo_cache / "snapshots"
|
||||||
|
if snapshots_dir.exists():
|
||||||
|
has_weights = (
|
||||||
|
any(snapshots_dir.rglob("*.safetensors")) or
|
||||||
|
any(snapshots_dir.rglob("*.bin")) or
|
||||||
|
any(snapshots_dir.rglob("*.npz"))
|
||||||
|
)
|
||||||
|
if not has_weights:
|
||||||
|
print(f"[_is_model_cached] No model weights found for whisper-{model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[_is_model_cached] Error checking cache for whisper-{model_size}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
async def load_model_async(self, model_size: Optional[str] = None):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the MLX Whisper model.
|
Lazy load the MLX Whisper model.
|
||||||
@@ -354,55 +495,58 @@ class MLXSTTBackend:
|
|||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
try:
|
try:
|
||||||
# IMPORTANT: Set up progress tracking BEFORE importing mlx_audio
|
|
||||||
# This ensures tqdm is patched before any HuggingFace Hub imports
|
|
||||||
progress_manager = get_progress_manager()
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
progress_model_name = f"whisper-{model_size}"
|
progress_model_name = f"whisper-{model_size}"
|
||||||
|
|
||||||
|
# Check if model is already cached
|
||||||
|
is_cached = self._is_model_cached(model_size)
|
||||||
|
|
||||||
# Set up progress callback and tracker
|
# Set up progress callback and tracker
|
||||||
|
# If cached: filter out non-download progress
|
||||||
|
# If not cached: report all progress (we're actually downloading)
|
||||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
||||||
tracker = HFProgressTracker(progress_callback)
|
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||||
|
|
||||||
# Patch tqdm BEFORE importing mlx_audio
|
# Patch tqdm BEFORE importing mlx_audio
|
||||||
# This is critical because mlx_audio imports huggingface_hub which imports tqdm
|
|
||||||
print("[DEBUG] Starting tqdm patch BEFORE mlx_audio import")
|
|
||||||
tracker_context = tracker.patch_download()
|
tracker_context = tracker.patch_download()
|
||||||
tracker_context.__enter__()
|
tracker_context.__enter__()
|
||||||
print("[DEBUG] tqdm patched, now importing mlx_audio")
|
|
||||||
|
|
||||||
# NOW import mlx_audio - it will use our patched tqdm
|
# Import mlx_audio
|
||||||
from mlx_audio.stt import load
|
from mlx_audio.stt import load
|
||||||
|
|
||||||
# MLX Whisper uses the standard OpenAI models
|
# MLX Whisper uses the standard OpenAI models
|
||||||
model_name = f"openai/whisper-{model_size}"
|
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||||
|
|
||||||
# Start tracking download task
|
|
||||||
task_manager = get_task_manager()
|
|
||||||
task_manager.start_download(progress_model_name)
|
|
||||||
|
|
||||||
print(f"Loading MLX Whisper model {model_size}...")
|
print(f"Loading MLX Whisper model {model_size}...")
|
||||||
|
|
||||||
# Initialize progress state
|
# Only track download progress if model is NOT cached
|
||||||
progress_manager.update_progress(
|
if not is_cached:
|
||||||
model_name=progress_model_name,
|
# Start tracking download task
|
||||||
current=0,
|
task_manager.start_download(progress_model_name)
|
||||||
total=1,
|
|
||||||
filename="",
|
# Initialize progress state so SSE endpoint has initial data to send
|
||||||
status="downloading",
|
progress_manager.update_progress(
|
||||||
)
|
model_name=progress_model_name,
|
||||||
|
current=0,
|
||||||
|
total=0,
|
||||||
|
filename="Connecting to HuggingFace...",
|
||||||
|
status="downloading",
|
||||||
|
)
|
||||||
|
|
||||||
# Load the model (tqdm is already patched from above)
|
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||||
try:
|
try:
|
||||||
self.model = load(model_name)
|
self.model = load(model_name)
|
||||||
finally:
|
finally:
|
||||||
# Exit the patch context
|
# Exit the patch context
|
||||||
tracker_context.__exit__(None, None, None)
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
self.model_size = model_size
|
# Only mark download as complete if we were tracking it
|
||||||
|
if not is_cached:
|
||||||
|
progress_manager.mark_complete(progress_model_name)
|
||||||
|
task_manager.complete_download(progress_model_name)
|
||||||
|
|
||||||
# Mark as complete
|
self.model_size = model_size
|
||||||
progress_manager.mark_complete(progress_model_name)
|
|
||||||
task_manager.complete_download(progress_model_name)
|
|
||||||
|
|
||||||
print(f"MLX Whisper model {model_size} loaded successfully")
|
print(f"MLX Whisper model {model_size} loaded successfully")
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ from ..utils.progress import get_progress_manager
|
|||||||
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||||
from ..utils.tasks import get_task_manager
|
from ..utils.tasks import get_task_manager
|
||||||
|
|
||||||
|
LANGUAGE_CODE_TO_NAME = {
|
||||||
|
"zh": "chinese", "en": "english", "ja": "japanese", "ko": "korean",
|
||||||
|
"de": "german", "fr": "french", "ru": "russian", "pt": "portuguese",
|
||||||
|
"es": "spanish", "it": "italian",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PyTorchTTSBackend:
|
class PyTorchTTSBackend:
|
||||||
"""PyTorch-based TTS backend using Qwen3-TTS."""
|
"""PyTorch-based TTS backend using Qwen3-TTS."""
|
||||||
@@ -29,9 +35,23 @@ class PyTorchTTSBackend:
|
|||||||
"""Get the best available device."""
|
"""Get the best available device."""
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
return "cuda"
|
return "cuda"
|
||||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
# Intel Arc / Intel Xe GPU via intel-extension-for-pytorch (IPEX)
|
||||||
# MPS can have issues, use CPU for stability
|
try:
|
||||||
return "cpu"
|
import intel_extension_for_pytorch # noqa: F401
|
||||||
|
if hasattr(torch, 'xpu') and torch.xpu.is_available():
|
||||||
|
return "xpu"
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
# Any GPU on Windows via DirectML (torch-directml)
|
||||||
|
try:
|
||||||
|
import torch_directml
|
||||||
|
if torch_directml.device_count() > 0:
|
||||||
|
return torch_directml.device(0)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
# MPS (Apple Silicon) — kept for completeness but MLX backend is preferred
|
||||||
|
if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||||
|
return "cpu" # MPS disabled for stability; MLX backend handles Apple Silicon
|
||||||
return "cpu"
|
return "cpu"
|
||||||
|
|
||||||
def is_loaded(self) -> bool:
|
def is_loaded(self) -> bool:
|
||||||
@@ -58,6 +78,46 @@ class PyTorchTTSBackend:
|
|||||||
|
|
||||||
return hf_model_map[model_size]
|
return hf_model_map[model_size]
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the model is already cached locally AND fully downloaded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_size: Model size to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if model is fully cached, False if missing or incomplete
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
model_path = self._get_model_path(model_size)
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
|
||||||
|
|
||||||
|
if not repo_cache.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for .incomplete files - if any exist, download is still in progress
|
||||||
|
blobs_dir = repo_cache / "blobs"
|
||||||
|
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||||
|
print(f"[_is_model_cached] Found .incomplete files for {model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check that actual model weight files exist in snapshots
|
||||||
|
snapshots_dir = repo_cache / "snapshots"
|
||||||
|
if snapshots_dir.exists():
|
||||||
|
has_weights = (
|
||||||
|
any(snapshots_dir.rglob("*.safetensors")) or
|
||||||
|
any(snapshots_dir.rglob("*.bin"))
|
||||||
|
)
|
||||||
|
if not has_weights:
|
||||||
|
print(f"[_is_model_cached] No model weights found for {model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[_is_model_cached] Error checking cache for {model_size}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
async def load_model_async(self, model_size: Optional[str] = None):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the TTS model with automatic downloading from HuggingFace Hub.
|
Lazy load the TTS model with automatic downloading from HuggingFace Hub.
|
||||||
@@ -85,20 +145,24 @@ class PyTorchTTSBackend:
|
|||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
try:
|
try:
|
||||||
# IMPORTANT: Set up progress tracking BEFORE importing qwen_tts
|
|
||||||
# This ensures tqdm is patched before any HuggingFace Hub imports
|
|
||||||
progress_manager = get_progress_manager()
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
model_name = f"qwen-tts-{model_size}"
|
model_name = f"qwen-tts-{model_size}"
|
||||||
|
|
||||||
|
# Check if model is already cached
|
||||||
|
is_cached = self._is_model_cached(model_size)
|
||||||
|
|
||||||
# Set up progress callback and tracker
|
# Set up progress callback and tracker
|
||||||
|
# If cached: filter out non-download progress (like "Segment 1/1" during generation)
|
||||||
|
# If not cached: report all progress (we're actually downloading)
|
||||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||||
tracker = HFProgressTracker(progress_callback)
|
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||||
|
|
||||||
# Patch tqdm BEFORE importing qwen_tts
|
# Patch tqdm BEFORE importing qwen_tts
|
||||||
tracker_context = tracker.patch_download()
|
tracker_context = tracker.patch_download()
|
||||||
tracker_context.__enter__()
|
tracker_context.__enter__()
|
||||||
|
|
||||||
# NOW import qwen_tts - it will use our patched tqdm
|
# Import qwen_tts
|
||||||
from qwen_tts import Qwen3TTSModel
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
|
||||||
# Get model path (local or HuggingFace Hub ID)
|
# Get model path (local or HuggingFace Hub ID)
|
||||||
@@ -106,33 +170,45 @@ class PyTorchTTSBackend:
|
|||||||
|
|
||||||
print(f"Loading TTS model {model_size} on {self.device}...")
|
print(f"Loading TTS model {model_size} on {self.device}...")
|
||||||
|
|
||||||
# Start tracking download task
|
# Only track download progress if model is NOT cached
|
||||||
task_manager = get_task_manager()
|
if not is_cached:
|
||||||
task_manager.start_download(model_name)
|
# Start tracking download task
|
||||||
|
task_manager.start_download(model_name)
|
||||||
|
|
||||||
# Initialize progress state to show download has started
|
# Initialize progress state so SSE endpoint has initial data to send
|
||||||
progress_manager.update_progress(
|
progress_manager.update_progress(
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
current=0,
|
current=0,
|
||||||
total=1, # Set to 1 initially, will be updated by callback
|
total=0, # Will be updated once actual total is known
|
||||||
filename="",
|
filename="Connecting to HuggingFace...",
|
||||||
status="downloading",
|
status="downloading",
|
||||||
)
|
|
||||||
|
|
||||||
# Load the model (tqdm is already patched from above)
|
|
||||||
try:
|
|
||||||
self.model = Qwen3TTSModel.from_pretrained(
|
|
||||||
model_path,
|
|
||||||
device_map=self.device,
|
|
||||||
torch_dtype=torch.float32 if self.device == "cpu" else torch.bfloat16,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||||
|
try:
|
||||||
|
# Don't pass device_map on CPU: accelerate's meta-tensor mechanism
|
||||||
|
# causes "Cannot copy out of meta tensor" when moving to CPU.
|
||||||
|
# Instead load directly then call .to(device) if needed.
|
||||||
|
if self.device == "cpu":
|
||||||
|
self.model = Qwen3TTSModel.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
torch_dtype=torch.float32,
|
||||||
|
low_cpu_mem_usage=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.model = Qwen3TTSModel.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
device_map=self.device,
|
||||||
|
torch_dtype=torch.bfloat16,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
# Exit the patch context
|
# Exit the patch context
|
||||||
tracker_context.__exit__(None, None, None)
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
# Mark as complete
|
# Only mark download as complete if we were tracking it
|
||||||
progress_manager.mark_complete(model_name)
|
if not is_cached:
|
||||||
task_manager.complete_download(model_name)
|
progress_manager.mark_complete(model_name)
|
||||||
|
task_manager.complete_download(model_name)
|
||||||
|
|
||||||
self._current_model_size = model_size
|
self._current_model_size = model_size
|
||||||
self.model_size = model_size
|
self.model_size = model_size
|
||||||
@@ -289,6 +365,7 @@ class PyTorchTTSBackend:
|
|||||||
wavs, sample_rate = self.model.generate_voice_clone(
|
wavs, sample_rate = self.model.generate_voice_clone(
|
||||||
text=text,
|
text=text,
|
||||||
voice_clone_prompt=voice_prompt,
|
voice_clone_prompt=voice_prompt,
|
||||||
|
language=LANGUAGE_CODE_TO_NAME.get(language, "auto"),
|
||||||
instruct=instruct,
|
instruct=instruct,
|
||||||
)
|
)
|
||||||
return wavs[0], sample_rate
|
return wavs[0], sample_rate
|
||||||
@@ -299,9 +376,18 @@ class PyTorchTTSBackend:
|
|||||||
return audio, sample_rate
|
return audio, sample_rate
|
||||||
|
|
||||||
|
|
||||||
|
WHISPER_HF_REPOS = {
|
||||||
|
"base": "openai/whisper-base",
|
||||||
|
"small": "openai/whisper-small",
|
||||||
|
"medium": "openai/whisper-medium",
|
||||||
|
"large": "openai/whisper-large-v3",
|
||||||
|
"turbo": "openai/whisper-large-v3-turbo",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PyTorchSTTBackend:
|
class PyTorchSTTBackend:
|
||||||
"""PyTorch-based STT backend using Whisper."""
|
"""PyTorch-based STT backend using Whisper."""
|
||||||
|
|
||||||
def __init__(self, model_size: str = "base"):
|
def __init__(self, model_size: str = "base"):
|
||||||
self.model = None
|
self.model = None
|
||||||
self.processor = None
|
self.processor = None
|
||||||
@@ -312,15 +398,68 @@ class PyTorchSTTBackend:
|
|||||||
"""Get the best available device."""
|
"""Get the best available device."""
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
return "cuda"
|
return "cuda"
|
||||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
# Intel Arc / Intel Xe GPU via intel-extension-for-pytorch (IPEX)
|
||||||
# MPS support for Whisper
|
try:
|
||||||
return "cpu" # Use CPU for stability
|
import intel_extension_for_pytorch # noqa: F401
|
||||||
|
if hasattr(torch, 'xpu') and torch.xpu.is_available():
|
||||||
|
return "xpu"
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
# Any GPU on Windows via DirectML (torch-directml)
|
||||||
|
try:
|
||||||
|
import torch_directml
|
||||||
|
if torch_directml.device_count() > 0:
|
||||||
|
return torch_directml.device(0)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||||
|
return "cpu" # MPS disabled for stability
|
||||||
return "cpu"
|
return "cpu"
|
||||||
|
|
||||||
def is_loaded(self) -> bool:
|
def is_loaded(self) -> bool:
|
||||||
"""Check if model is loaded."""
|
"""Check if model is loaded."""
|
||||||
return self.model is not None
|
return self.model is not None
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the Whisper model is already cached locally AND fully downloaded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_size: Model size to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if model is fully cached, False if missing or incomplete
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + hf_repo.replace("/", "--"))
|
||||||
|
|
||||||
|
if not repo_cache.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for .incomplete files - if any exist, download is still in progress
|
||||||
|
blobs_dir = repo_cache / "blobs"
|
||||||
|
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||||
|
print(f"[_is_model_cached] Found .incomplete files for whisper-{model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check that actual model weight files exist in snapshots
|
||||||
|
snapshots_dir = repo_cache / "snapshots"
|
||||||
|
if snapshots_dir.exists():
|
||||||
|
has_weights = (
|
||||||
|
any(snapshots_dir.rglob("*.safetensors")) or
|
||||||
|
any(snapshots_dir.rglob("*.bin"))
|
||||||
|
)
|
||||||
|
if not has_weights:
|
||||||
|
print(f"[_is_model_cached] No model weights found for whisper-{model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[_is_model_cached] Error checking cache for whisper-{model_size}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
async def load_model_async(self, model_size: Optional[str] = None):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the Whisper model.
|
Lazy load the Whisper model.
|
||||||
@@ -349,14 +488,18 @@ class PyTorchSTTBackend:
|
|||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
print(f"[DEBUG] _load_model_sync called for Whisper {model_size}")
|
print(f"[DEBUG] _load_model_sync called for Whisper {model_size}")
|
||||||
try:
|
try:
|
||||||
# IMPORTANT: Set up progress tracking BEFORE importing transformers
|
|
||||||
# This ensures tqdm is patched before any HuggingFace Hub imports
|
|
||||||
progress_manager = get_progress_manager()
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
progress_model_name = f"whisper-{model_size}"
|
progress_model_name = f"whisper-{model_size}"
|
||||||
|
|
||||||
|
# Check if model is already cached
|
||||||
|
is_cached = self._is_model_cached(model_size)
|
||||||
|
|
||||||
# Set up progress callback and tracker
|
# Set up progress callback and tracker
|
||||||
|
# If cached: filter out non-download progress
|
||||||
|
# If not cached: report all progress (we're actually downloading)
|
||||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
||||||
tracker = HFProgressTracker(progress_callback)
|
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||||
|
|
||||||
# Patch tqdm BEFORE importing transformers
|
# Patch tqdm BEFORE importing transformers
|
||||||
print("[DEBUG] Starting tqdm patch BEFORE transformers import")
|
print("[DEBUG] Starting tqdm patch BEFORE transformers import")
|
||||||
@@ -364,31 +507,29 @@ class PyTorchSTTBackend:
|
|||||||
tracker_context.__enter__()
|
tracker_context.__enter__()
|
||||||
print("[DEBUG] tqdm patched, now importing transformers")
|
print("[DEBUG] tqdm patched, now importing transformers")
|
||||||
|
|
||||||
# NOW import transformers - it will use our patched tqdm
|
# Import transformers
|
||||||
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||||
|
|
||||||
model_name = f"openai/whisper-{model_size}"
|
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||||
print(f"[DEBUG] Model name: {model_name}")
|
print(f"[DEBUG] Model name: {model_name}")
|
||||||
|
|
||||||
# Start tracking download task
|
|
||||||
task_manager = get_task_manager()
|
|
||||||
task_manager.start_download(progress_model_name)
|
|
||||||
print(f"[DEBUG] Task manager started download")
|
|
||||||
|
|
||||||
print(f"Loading Whisper model {model_size} on {self.device}...")
|
print(f"Loading Whisper model {model_size} on {self.device}...")
|
||||||
|
|
||||||
# Initialize progress state to show download has started
|
# Only track download progress if model is NOT cached
|
||||||
print(f"[DEBUG] Calling update_progress...")
|
if not is_cached:
|
||||||
progress_manager.update_progress(
|
# Start tracking download task
|
||||||
model_name=progress_model_name,
|
task_manager.start_download(progress_model_name)
|
||||||
current=0,
|
|
||||||
total=1, # Set to 1 initially, will be updated by callback
|
|
||||||
filename="",
|
|
||||||
status="downloading",
|
|
||||||
)
|
|
||||||
print(f"[DEBUG] update_progress called, listeners: {len(progress_manager._listeners.get(progress_model_name, []))}")
|
|
||||||
|
|
||||||
# Load models (tqdm is already patched from above)
|
# Initialize progress state so SSE endpoint has initial data to send
|
||||||
|
progress_manager.update_progress(
|
||||||
|
model_name=progress_model_name,
|
||||||
|
current=0,
|
||||||
|
total=0, # Will be updated once actual total is known
|
||||||
|
filename="Connecting to HuggingFace...",
|
||||||
|
status="downloading",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load models (tqdm is patched, but filters out non-download progress)
|
||||||
try:
|
try:
|
||||||
self.processor = WhisperProcessor.from_pretrained(model_name)
|
self.processor = WhisperProcessor.from_pretrained(model_name)
|
||||||
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
||||||
@@ -396,13 +537,14 @@ class PyTorchSTTBackend:
|
|||||||
# Exit the patch context
|
# Exit the patch context
|
||||||
tracker_context.__exit__(None, None, None)
|
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.to(self.device)
|
||||||
self.model_size = model_size
|
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")
|
print(f"Whisper model {model_size} loaded successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -457,21 +599,20 @@ class PyTorchSTTBackend:
|
|||||||
)
|
)
|
||||||
inputs = inputs.to(self.device)
|
inputs = inputs.to(self.device)
|
||||||
|
|
||||||
# Set language if provided
|
# Generate transcription
|
||||||
forced_decoder_ids = None
|
# If language is provided, force it; otherwise let Whisper auto-detect
|
||||||
|
generate_kwargs = {}
|
||||||
if language:
|
if language:
|
||||||
# Support all languages from frontend: en, zh, ja, ko, de, fr, ru, pt, es, it
|
|
||||||
# Whisper supports these and many more
|
|
||||||
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
||||||
language=language,
|
language=language,
|
||||||
task="transcribe",
|
task="transcribe",
|
||||||
)
|
)
|
||||||
|
generate_kwargs["forced_decoder_ids"] = forced_decoder_ids
|
||||||
|
|
||||||
# Generate transcription
|
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
predicted_ids = self.model.generate(
|
predicted_ids = self.model.generate(
|
||||||
inputs["input_features"],
|
inputs["input_features"],
|
||||||
forced_decoder_ids=forced_decoder_ids,
|
**generate_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Decode
|
# Decode
|
||||||
|
|||||||
+43
-11
@@ -1,8 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
PyInstaller build script for creating standalone Python server binary.
|
PyInstaller build script for creating standalone Python server binary.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python build_binary.py # Build default (CPU) server binary
|
||||||
|
python build_binary.py --cuda # Build CUDA-enabled server binary
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import PyInstaller.__main__
|
import PyInstaller.__main__
|
||||||
|
import argparse
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -13,15 +18,22 @@ def is_apple_silicon():
|
|||||||
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
||||||
|
|
||||||
|
|
||||||
def build_server():
|
def build_server(cuda=False):
|
||||||
"""Build Python server as standalone binary."""
|
"""Build Python server as standalone binary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cuda: If True, build with CUDA support and name the binary
|
||||||
|
voicebox-server-cuda instead of voicebox-server.
|
||||||
|
"""
|
||||||
backend_dir = Path(__file__).parent
|
backend_dir = Path(__file__).parent
|
||||||
|
|
||||||
|
binary_name = 'voicebox-server-cuda' if cuda else 'voicebox-server'
|
||||||
|
|
||||||
# PyInstaller arguments
|
# PyInstaller arguments
|
||||||
args = [
|
args = [
|
||||||
'server.py', # Use server.py as entry point instead of main.py
|
'server.py', # Use server.py as entry point instead of main.py
|
||||||
'--onefile',
|
'--onefile',
|
||||||
'--name', 'voicebox-server',
|
'--name', binary_name,
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add local qwen_tts path if specified (for editable installs)
|
# Add local qwen_tts path if specified (for editable installs)
|
||||||
@@ -49,6 +61,7 @@ def build_server():
|
|||||||
'--hidden-import', 'backend.utils.progress',
|
'--hidden-import', 'backend.utils.progress',
|
||||||
'--hidden-import', 'backend.utils.hf_progress',
|
'--hidden-import', 'backend.utils.hf_progress',
|
||||||
'--hidden-import', 'backend.utils.validation',
|
'--hidden-import', 'backend.utils.validation',
|
||||||
|
'--hidden-import', 'backend.cuda_download',
|
||||||
'--hidden-import', 'torch',
|
'--hidden-import', 'torch',
|
||||||
'--hidden-import', 'transformers',
|
'--hidden-import', 'transformers',
|
||||||
'--hidden-import', 'fastapi',
|
'--hidden-import', 'fastapi',
|
||||||
@@ -70,8 +83,16 @@ def build_server():
|
|||||||
'--collect-submodules', 'jaraco',
|
'--collect-submodules', 'jaraco',
|
||||||
])
|
])
|
||||||
|
|
||||||
# Add MLX-specific imports if building on Apple Silicon
|
# Add CUDA-specific hidden imports
|
||||||
if is_apple_silicon():
|
if cuda:
|
||||||
|
print("Building with CUDA support")
|
||||||
|
args.extend([
|
||||||
|
'--hidden-import', 'torch.cuda',
|
||||||
|
'--hidden-import', 'torch.backends.cudnn',
|
||||||
|
])
|
||||||
|
|
||||||
|
# Add MLX-specific imports if building on Apple Silicon (never for CUDA builds)
|
||||||
|
if is_apple_silicon() and not cuda:
|
||||||
print("Building for Apple Silicon - including MLX dependencies")
|
print("Building for Apple Silicon - including MLX dependencies")
|
||||||
args.extend([
|
args.extend([
|
||||||
'--hidden-import', 'backend.backends.mlx_backend',
|
'--hidden-import', 'backend.backends.mlx_backend',
|
||||||
@@ -83,11 +104,15 @@ def build_server():
|
|||||||
'--hidden-import', 'mlx_audio.stt',
|
'--hidden-import', 'mlx_audio.stt',
|
||||||
'--collect-submodules', 'mlx',
|
'--collect-submodules', 'mlx',
|
||||||
'--collect-submodules', 'mlx_audio',
|
'--collect-submodules', 'mlx_audio',
|
||||||
# Collect MLX data files including Metal shader libraries (.metallib)
|
# Use --collect-all so PyInstaller bundles both data files AND
|
||||||
'--collect-data', 'mlx',
|
# native shared libraries (.dylib, .metallib) for MLX.
|
||||||
'--collect-data', 'mlx_audio',
|
# Previously only --collect-data was used, which caused MLX to
|
||||||
|
# raise OSError at runtime inside the bundled binary because
|
||||||
|
# the Metal shader libraries were missing.
|
||||||
|
'--collect-all', 'mlx',
|
||||||
|
'--collect-all', 'mlx_audio',
|
||||||
])
|
])
|
||||||
else:
|
elif not cuda:
|
||||||
print("Building for non-Apple Silicon platform - PyTorch only")
|
print("Building for non-Apple Silicon platform - PyTorch only")
|
||||||
|
|
||||||
args.extend([
|
args.extend([
|
||||||
@@ -101,8 +126,15 @@ def build_server():
|
|||||||
# Run PyInstaller
|
# Run PyInstaller
|
||||||
PyInstaller.__main__.run(args)
|
PyInstaller.__main__.run(args)
|
||||||
|
|
||||||
print(f"Binary built in {backend_dir / 'dist' / 'voicebox-server'}")
|
print(f"Binary built in {backend_dir / 'dist' / binary_name}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
build_server()
|
parser = argparse.ArgumentParser(description="Build voicebox-server binary")
|
||||||
|
parser.add_argument(
|
||||||
|
'--cuda',
|
||||||
|
action='store_true',
|
||||||
|
help="Build CUDA-enabled binary (voicebox-server-cuda)",
|
||||||
|
)
|
||||||
|
cli_args = parser.parse_args()
|
||||||
|
build_server(cuda=cli_args.cuda)
|
||||||
|
|||||||
@@ -4,8 +4,17 @@ Configuration module for voicebox backend.
|
|||||||
Handles data directory configuration for production bundling.
|
Handles data directory configuration for production bundling.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Allow users to override the HuggingFace model download directory.
|
||||||
|
# Set VOICEBOX_MODELS_DIR to an absolute path before starting the server.
|
||||||
|
# This sets HF_HUB_CACHE so all huggingface_hub downloads go to that path.
|
||||||
|
_custom_models_dir = os.environ.get("VOICEBOX_MODELS_DIR")
|
||||||
|
if _custom_models_dir:
|
||||||
|
os.environ["HF_HUB_CACHE"] = _custom_models_dir
|
||||||
|
print(f"[config] Model download path set to: {_custom_models_dir}")
|
||||||
|
|
||||||
# Default data directory (used in development)
|
# Default data directory (used in development)
|
||||||
_data_dir = Path("data")
|
_data_dir = Path("data")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""
|
||||||
|
CUDA backend binary download, assembly, and verification.
|
||||||
|
|
||||||
|
Downloads split parts of the CUDA-enabled voicebox-server binary from
|
||||||
|
GitHub Releases, reassembles them, verifies integrity via SHA-256,
|
||||||
|
and places the binary in the app's data directory for use on next
|
||||||
|
backend restart.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .config import get_data_dir
|
||||||
|
from .utils.progress import get_progress_manager
|
||||||
|
from . import __version__
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
GITHUB_RELEASES_URL = "https://github.com/jamiepine/voicebox/releases/download"
|
||||||
|
|
||||||
|
PROGRESS_KEY = "cuda-backend"
|
||||||
|
|
||||||
|
|
||||||
|
def get_backends_dir() -> Path:
|
||||||
|
"""Directory where downloaded backend binaries are stored."""
|
||||||
|
d = get_data_dir() / "backends"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def get_cuda_binary_name() -> str:
|
||||||
|
"""Platform-specific CUDA binary filename."""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return "voicebox-server-cuda.exe"
|
||||||
|
return "voicebox-server-cuda"
|
||||||
|
|
||||||
|
|
||||||
|
def get_cuda_binary_path() -> Optional[Path]:
|
||||||
|
"""Return path to CUDA binary if it exists."""
|
||||||
|
p = get_backends_dir() / get_cuda_binary_name()
|
||||||
|
if p.exists():
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_cuda_active() -> bool:
|
||||||
|
"""Check if the current process is the CUDA binary.
|
||||||
|
|
||||||
|
The CUDA binary sets this env var on startup (see server.py).
|
||||||
|
"""
|
||||||
|
return os.environ.get("VOICEBOX_BACKEND_VARIANT") == "cuda"
|
||||||
|
|
||||||
|
|
||||||
|
def get_cuda_status() -> dict:
|
||||||
|
"""Get current CUDA backend status for the API."""
|
||||||
|
progress_manager = get_progress_manager()
|
||||||
|
cuda_path = get_cuda_binary_path()
|
||||||
|
progress = progress_manager.get_progress(PROGRESS_KEY)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"available": cuda_path is not None,
|
||||||
|
"active": is_cuda_active(),
|
||||||
|
"binary_path": str(cuda_path) if cuda_path else None,
|
||||||
|
"downloading": progress is not None and progress.get("status") == "downloading",
|
||||||
|
"download_progress": progress,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def download_cuda_binary(version: Optional[str] = None):
|
||||||
|
"""Download the CUDA backend binary from GitHub Releases.
|
||||||
|
|
||||||
|
Downloads split parts listed in a manifest file, concatenates them,
|
||||||
|
and verifies the SHA-256 checksum for integrity. Atomic write
|
||||||
|
(temp file -> rename).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
version: Version tag (e.g. "v0.2.0"). Defaults to current app version.
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
if version is None:
|
||||||
|
version = f"v{__version__}"
|
||||||
|
|
||||||
|
progress = get_progress_manager()
|
||||||
|
binary_name = get_cuda_binary_name()
|
||||||
|
dest_dir = get_backends_dir()
|
||||||
|
final_path = dest_dir / binary_name
|
||||||
|
temp_path = dest_dir / f"{binary_name}.download"
|
||||||
|
|
||||||
|
# Clean up any leftover partial download
|
||||||
|
if temp_path.exists():
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
logger.info(f"Starting CUDA backend download for {version}")
|
||||||
|
progress.update_progress(
|
||||||
|
PROGRESS_KEY, current=0, total=0,
|
||||||
|
filename="Fetching manifest...", status="downloading",
|
||||||
|
)
|
||||||
|
|
||||||
|
base_url = f"{GITHUB_RELEASES_URL}/{version}"
|
||||||
|
stem = Path(binary_name).stem # voicebox-server-cuda
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
|
||||||
|
# Fetch the manifest (list of split part filenames)
|
||||||
|
manifest_url = f"{base_url}/{stem}.manifest"
|
||||||
|
manifest_resp = await client.get(manifest_url)
|
||||||
|
manifest_resp.raise_for_status()
|
||||||
|
parts = [p.strip() for p in manifest_resp.text.strip().splitlines() if p.strip()]
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
raise ValueError("Empty manifest — no split parts found")
|
||||||
|
|
||||||
|
logger.info(f"Found {len(parts)} split parts to download")
|
||||||
|
|
||||||
|
# Fetch expected checksum (optional — for integrity verification)
|
||||||
|
expected_sha = None
|
||||||
|
try:
|
||||||
|
sha_url = f"{base_url}/{stem}.sha256"
|
||||||
|
sha_resp = await client.get(sha_url)
|
||||||
|
if sha_resp.status_code == 200:
|
||||||
|
# Format: "sha256hex filename\n"
|
||||||
|
expected_sha = sha_resp.text.strip().split()[0]
|
||||||
|
logger.info(f"Expected SHA-256: {expected_sha[:16]}...")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not fetch checksum file — skipping verification: {e}")
|
||||||
|
|
||||||
|
# Download and concatenate parts
|
||||||
|
total_downloaded = 0
|
||||||
|
with open(temp_path, "wb") as f:
|
||||||
|
for i, part_name in enumerate(parts):
|
||||||
|
part_url = f"{base_url}/{part_name}"
|
||||||
|
logger.info(f"Downloading part {i + 1}/{len(parts)}: {part_name}")
|
||||||
|
|
||||||
|
async with client.stream("GET", part_url) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
|
||||||
|
f.write(chunk)
|
||||||
|
total_downloaded += len(chunk)
|
||||||
|
progress.update_progress(
|
||||||
|
PROGRESS_KEY, current=total_downloaded, total=0,
|
||||||
|
filename=f"Part {i + 1}/{len(parts)}",
|
||||||
|
status="downloading",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify integrity if checksum was available
|
||||||
|
if expected_sha:
|
||||||
|
progress.update_progress(
|
||||||
|
PROGRESS_KEY, current=total_downloaded, total=total_downloaded,
|
||||||
|
filename="Verifying integrity...", status="downloading",
|
||||||
|
)
|
||||||
|
sha256 = hashlib.sha256()
|
||||||
|
with open(temp_path, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
sha256.update(chunk)
|
||||||
|
|
||||||
|
actual = sha256.hexdigest()
|
||||||
|
if actual != expected_sha:
|
||||||
|
raise ValueError(
|
||||||
|
f"Integrity check failed: expected {expected_sha[:16]}..., "
|
||||||
|
f"got {actual[:16]}..."
|
||||||
|
)
|
||||||
|
logger.info(f"Integrity verified: {actual[:16]}...")
|
||||||
|
|
||||||
|
# Atomic move into place (replace handles existing target on all platforms)
|
||||||
|
temp_path.replace(final_path)
|
||||||
|
|
||||||
|
# Make executable on Unix
|
||||||
|
if sys.platform != "win32":
|
||||||
|
final_path.chmod(0o755)
|
||||||
|
|
||||||
|
logger.info(f"CUDA backend downloaded to {final_path}")
|
||||||
|
progress.mark_complete(PROGRESS_KEY)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Clean up on failure
|
||||||
|
if temp_path.exists():
|
||||||
|
temp_path.unlink()
|
||||||
|
logger.error(f"CUDA backend download failed: {e}")
|
||||||
|
progress.mark_error(PROGRESS_KEY, str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_cuda_binary() -> bool:
|
||||||
|
"""Delete the downloaded CUDA binary. Returns True if deleted."""
|
||||||
|
path = get_cuda_binary_path()
|
||||||
|
if path and path.exists():
|
||||||
|
path.unlink()
|
||||||
|
logger.info(f"Deleted CUDA binary: {path}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
+36
-2
@@ -45,10 +45,14 @@ class Generation(Base):
|
|||||||
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
|
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
|
||||||
text = Column(Text, nullable=False)
|
text = Column(Text, nullable=False)
|
||||||
language = Column(String, default="en")
|
language = Column(String, default="en")
|
||||||
audio_path = Column(String, nullable=False)
|
audio_path = Column(String, nullable=True)
|
||||||
duration = Column(Float, nullable=False)
|
duration = Column(Float, nullable=True)
|
||||||
seed = Column(Integer)
|
seed = Column(Integer)
|
||||||
instruct = Column(Text)
|
instruct = Column(Text)
|
||||||
|
engine = Column(String, default="qwen")
|
||||||
|
model_size = Column(String, nullable=True)
|
||||||
|
status = Column(String, default="completed") # generating, completed, failed
|
||||||
|
error = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
@@ -288,6 +292,36 @@ def _run_migrations(engine):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
print("Added avatar_path column to profiles")
|
print("Added avatar_path column to profiles")
|
||||||
|
|
||||||
|
# Migration: Add status and error columns to generations table
|
||||||
|
if 'generations' in inspector.get_table_names():
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('generations')}
|
||||||
|
if 'status' not in columns:
|
||||||
|
print("Migrating generations: adding status column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE generations ADD COLUMN status VARCHAR DEFAULT 'completed'"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added status column to generations")
|
||||||
|
if 'error' not in columns:
|
||||||
|
print("Migrating generations: adding error column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE generations ADD COLUMN error TEXT"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added error column to generations")
|
||||||
|
if 'engine' not in columns:
|
||||||
|
print("Migrating generations: adding engine column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE generations ADD COLUMN engine VARCHAR DEFAULT 'qwen'"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added engine column to generations")
|
||||||
|
# Re-read columns after engine migration (variable name shadows outer `engine`)
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('generations')}
|
||||||
|
if 'model_size' not in columns:
|
||||||
|
print("Migrating generations: adding model_size column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE generations ADD COLUMN model_size VARCHAR"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added model_size column to generations")
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""Get database session (generator for dependency injection)."""
|
"""Get database session (generator for dependency injection)."""
|
||||||
|
|||||||
+42
-1
@@ -29,6 +29,10 @@ async def create_generation(
|
|||||||
seed: Optional[int],
|
seed: Optional[int],
|
||||||
db: Session,
|
db: Session,
|
||||||
instruct: Optional[str] = None,
|
instruct: Optional[str] = None,
|
||||||
|
generation_id: Optional[str] = None,
|
||||||
|
status: str = "completed",
|
||||||
|
engine: Optional[str] = "qwen",
|
||||||
|
model_size: Optional[str] = None,
|
||||||
) -> GenerationResponse:
|
) -> GenerationResponse:
|
||||||
"""
|
"""
|
||||||
Create a new generation history entry.
|
Create a new generation history entry.
|
||||||
@@ -42,12 +46,16 @@ async def create_generation(
|
|||||||
seed: Random seed used (if any)
|
seed: Random seed used (if any)
|
||||||
db: Database session
|
db: Database session
|
||||||
instruct: Natural language instruction used (if any)
|
instruct: Natural language instruction used (if any)
|
||||||
|
generation_id: Pre-assigned ID (for async generation flow)
|
||||||
|
status: Generation status (generating, completed, failed)
|
||||||
|
engine: TTS engine used (qwen, luxtts, chatterbox, chatterbox_turbo)
|
||||||
|
model_size: Model size variant (1.7B, 0.6B) — only relevant for qwen
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created generation entry
|
Created generation entry
|
||||||
"""
|
"""
|
||||||
db_generation = DBGeneration(
|
db_generation = DBGeneration(
|
||||||
id=str(uuid.uuid4()),
|
id=generation_id or str(uuid.uuid4()),
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
text=text,
|
text=text,
|
||||||
language=language,
|
language=language,
|
||||||
@@ -55,6 +63,9 @@ async def create_generation(
|
|||||||
duration=duration,
|
duration=duration,
|
||||||
seed=seed,
|
seed=seed,
|
||||||
instruct=instruct,
|
instruct=instruct,
|
||||||
|
engine=engine,
|
||||||
|
model_size=model_size,
|
||||||
|
status=status,
|
||||||
created_at=datetime.utcnow(),
|
created_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,6 +76,32 @@ async def create_generation(
|
|||||||
return GenerationResponse.model_validate(db_generation)
|
return GenerationResponse.model_validate(db_generation)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_generation_status(
|
||||||
|
generation_id: str,
|
||||||
|
status: str,
|
||||||
|
db: Session,
|
||||||
|
audio_path: Optional[str] = None,
|
||||||
|
duration: Optional[float] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
) -> Optional[GenerationResponse]:
|
||||||
|
"""Update the status of a generation (used by async generation flow)."""
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
generation.status = status
|
||||||
|
if audio_path is not None:
|
||||||
|
generation.audio_path = audio_path
|
||||||
|
if duration is not None:
|
||||||
|
generation.duration = duration
|
||||||
|
if error is not None:
|
||||||
|
generation.error = error
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(generation)
|
||||||
|
return GenerationResponse.model_validate(generation)
|
||||||
|
|
||||||
|
|
||||||
async def get_generation(
|
async def get_generation(
|
||||||
generation_id: str,
|
generation_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -143,6 +180,10 @@ async def list_generations(
|
|||||||
duration=generation.duration,
|
duration=generation.duration,
|
||||||
seed=generation.seed,
|
seed=generation.seed,
|
||||||
instruct=generation.instruct,
|
instruct=generation.instruct,
|
||||||
|
engine=generation.engine or "qwen",
|
||||||
|
model_size=generation.model_size,
|
||||||
|
status=generation.status or "completed",
|
||||||
|
error=generation.error,
|
||||||
created_at=generation.created_at,
|
created_at=generation.created_at,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
+1122
-154
File diff suppressed because it is too large
Load Diff
+52
-11
@@ -11,7 +11,7 @@ class VoiceProfileCreate(BaseModel):
|
|||||||
"""Request model for creating a voice profile."""
|
"""Request model for creating a voice profile."""
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
description: Optional[str] = Field(None, max_length=500)
|
description: Optional[str] = Field(None, max_length=500)
|
||||||
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it)$")
|
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$")
|
||||||
|
|
||||||
|
|
||||||
class VoiceProfileResponse(BaseModel):
|
class VoiceProfileResponse(BaseModel):
|
||||||
@@ -52,11 +52,15 @@ class ProfileSampleResponse(BaseModel):
|
|||||||
class GenerationRequest(BaseModel):
|
class GenerationRequest(BaseModel):
|
||||||
"""Request model for voice generation."""
|
"""Request model for voice generation."""
|
||||||
profile_id: str
|
profile_id: str
|
||||||
text: str = Field(..., min_length=1, max_length=5000)
|
text: str = Field(..., min_length=1, max_length=50000)
|
||||||
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it)$")
|
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he)$")
|
||||||
seed: Optional[int] = Field(None, ge=0)
|
seed: Optional[int] = Field(None, ge=0)
|
||||||
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$")
|
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$")
|
||||||
instruct: Optional[str] = Field(None, max_length=500)
|
instruct: Optional[str] = Field(None, max_length=500)
|
||||||
|
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo)$")
|
||||||
|
max_chunk_chars: int = Field(default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting")
|
||||||
|
crossfade_ms: int = Field(default=50, ge=0, le=500, description="Crossfade duration in ms between chunks (0 for hard cut)")
|
||||||
|
normalize: bool = Field(default=True, description="Normalize output audio volume")
|
||||||
|
|
||||||
|
|
||||||
class GenerationResponse(BaseModel):
|
class GenerationResponse(BaseModel):
|
||||||
@@ -65,10 +69,14 @@ class GenerationResponse(BaseModel):
|
|||||||
profile_id: str
|
profile_id: str
|
||||||
text: str
|
text: str
|
||||||
language: str
|
language: str
|
||||||
audio_path: str
|
audio_path: Optional[str] = None
|
||||||
duration: float
|
duration: Optional[float] = None
|
||||||
seed: Optional[int]
|
seed: Optional[int] = None
|
||||||
instruct: Optional[str]
|
instruct: Optional[str] = None
|
||||||
|
engine: Optional[str] = "qwen"
|
||||||
|
model_size: Optional[str] = None
|
||||||
|
status: str = "completed"
|
||||||
|
error: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -90,10 +98,14 @@ class HistoryResponse(BaseModel):
|
|||||||
profile_name: str
|
profile_name: str
|
||||||
text: str
|
text: str
|
||||||
language: str
|
language: str
|
||||||
audio_path: str
|
audio_path: Optional[str] = None
|
||||||
duration: float
|
duration: Optional[float] = None
|
||||||
seed: Optional[int]
|
seed: Optional[int] = None
|
||||||
instruct: Optional[str]
|
instruct: Optional[str] = None
|
||||||
|
engine: Optional[str] = "qwen"
|
||||||
|
model_size: Optional[str] = None
|
||||||
|
status: str = "completed"
|
||||||
|
error: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -127,13 +139,32 @@ class HealthResponse(BaseModel):
|
|||||||
gpu_type: Optional[str] = None # GPU type (CUDA, MPS, or None)
|
gpu_type: Optional[str] = None # GPU type (CUDA, MPS, or None)
|
||||||
vram_used_mb: Optional[float] = None
|
vram_used_mb: Optional[float] = None
|
||||||
backend_type: Optional[str] = None # Backend type (mlx or pytorch)
|
backend_type: Optional[str] = None # Backend type (mlx or pytorch)
|
||||||
|
backend_variant: Optional[str] = None # Binary variant (cpu or cuda)
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryCheck(BaseModel):
|
||||||
|
"""Health status for a single directory."""
|
||||||
|
path: str
|
||||||
|
exists: bool
|
||||||
|
writable: bool
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FilesystemHealthResponse(BaseModel):
|
||||||
|
"""Response model for filesystem health check."""
|
||||||
|
healthy: bool
|
||||||
|
disk_free_mb: Optional[float] = None
|
||||||
|
disk_total_mb: Optional[float] = None
|
||||||
|
directories: List[DirectoryCheck]
|
||||||
|
|
||||||
|
|
||||||
class ModelStatus(BaseModel):
|
class ModelStatus(BaseModel):
|
||||||
"""Response model for model status."""
|
"""Response model for model status."""
|
||||||
model_name: str
|
model_name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
|
hf_repo_id: Optional[str] = None # HuggingFace repository ID
|
||||||
downloaded: bool
|
downloaded: bool
|
||||||
|
downloading: bool = False # True if download is in progress
|
||||||
size_mb: Optional[float] = None
|
size_mb: Optional[float] = None
|
||||||
loaded: bool = False
|
loaded: bool = False
|
||||||
|
|
||||||
@@ -148,11 +179,21 @@ class ModelDownloadRequest(BaseModel):
|
|||||||
model_name: str
|
model_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ModelMigrateRequest(BaseModel):
|
||||||
|
"""Request model for migrating models to a new directory."""
|
||||||
|
destination: str
|
||||||
|
|
||||||
|
|
||||||
class ActiveDownloadTask(BaseModel):
|
class ActiveDownloadTask(BaseModel):
|
||||||
"""Response model for active download task."""
|
"""Response model for active download task."""
|
||||||
model_name: str
|
model_name: str
|
||||||
status: str
|
status: str
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
|
error: Optional[str] = None
|
||||||
|
progress: Optional[float] = None # 0-100 percentage
|
||||||
|
current: Optional[int] = None # bytes downloaded
|
||||||
|
total: Optional[int] = None # total bytes
|
||||||
|
filename: Optional[str] = None # current file being downloaded
|
||||||
|
|
||||||
|
|
||||||
class ActiveGenerationTask(BaseModel):
|
class ActiveGenerationTask(BaseModel):
|
||||||
|
|||||||
@@ -19,15 +19,17 @@ def is_apple_silicon() -> bool:
|
|||||||
def get_backend_type() -> Literal["mlx", "pytorch"]:
|
def get_backend_type() -> Literal["mlx", "pytorch"]:
|
||||||
"""
|
"""
|
||||||
Detect the best backend for the current platform.
|
Detect the best backend for the current platform.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
"mlx" on Apple Silicon (if MLX is available), "pytorch" otherwise
|
"mlx" on Apple Silicon (if MLX is available and functional), "pytorch" otherwise
|
||||||
"""
|
"""
|
||||||
if is_apple_silicon():
|
if is_apple_silicon():
|
||||||
try:
|
try:
|
||||||
import mlx
|
import mlx.core # noqa: F401 — triggers native lib loading
|
||||||
return "mlx"
|
return "mlx"
|
||||||
except ImportError:
|
except (ImportError, OSError, RuntimeError):
|
||||||
# MLX not installed, fallback to PyTorch
|
# MLX not installed, or native libraries failed to load inside a
|
||||||
|
# PyInstaller bundle (OSError on missing .dylib / .metallib).
|
||||||
|
# Fall through to PyTorch.
|
||||||
return "pytorch"
|
return "pytorch"
|
||||||
return "pytorch"
|
return "pytorch"
|
||||||
|
|||||||
+32
-11
@@ -38,14 +38,22 @@ async def create_profile(
|
|||||||
) -> VoiceProfileResponse:
|
) -> VoiceProfileResponse:
|
||||||
"""
|
"""
|
||||||
Create a new voice profile.
|
Create a new voice profile.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Profile creation data
|
data: Profile creation data
|
||||||
db: Database session
|
db: Database session
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created profile
|
Created profile
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If a profile with the same name already exists
|
||||||
"""
|
"""
|
||||||
|
# Check if profile name already exists
|
||||||
|
existing_profile = db.query(DBVoiceProfile).filter_by(name=data.name).first()
|
||||||
|
if existing_profile:
|
||||||
|
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
|
||||||
|
|
||||||
# Create profile in database
|
# Create profile in database
|
||||||
db_profile = DBVoiceProfile(
|
db_profile = DBVoiceProfile(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
@@ -55,15 +63,15 @@ async def create_profile(
|
|||||||
created_at=datetime.utcnow(),
|
created_at=datetime.utcnow(),
|
||||||
updated_at=datetime.utcnow(),
|
updated_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(db_profile)
|
db.add(db_profile)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_profile)
|
db.refresh(db_profile)
|
||||||
|
|
||||||
# Create profile directory
|
# Create profile directory
|
||||||
profile_dir = _get_profiles_dir() / db_profile.id
|
profile_dir = _get_profiles_dir() / db_profile.id
|
||||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
return VoiceProfileResponse.model_validate(db_profile)
|
return VoiceProfileResponse.model_validate(db_profile)
|
||||||
|
|
||||||
|
|
||||||
@@ -191,28 +199,37 @@ async def update_profile(
|
|||||||
) -> Optional[VoiceProfileResponse]:
|
) -> Optional[VoiceProfileResponse]:
|
||||||
"""
|
"""
|
||||||
Update a voice profile.
|
Update a voice profile.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
profile_id: Profile ID
|
profile_id: Profile ID
|
||||||
data: Updated profile data
|
data: Updated profile data
|
||||||
db: Database session
|
db: Database session
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated profile or None if not found
|
Updated profile or None if not found
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If a profile with the same name already exists (different profile)
|
||||||
"""
|
"""
|
||||||
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||||
if not profile:
|
if not profile:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Check if the new name conflicts with another profile
|
||||||
|
if profile.name != data.name:
|
||||||
|
existing_profile = db.query(DBVoiceProfile).filter_by(name=data.name).first()
|
||||||
|
if existing_profile:
|
||||||
|
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
|
||||||
|
|
||||||
# Update fields
|
# Update fields
|
||||||
profile.name = data.name
|
profile.name = data.name
|
||||||
profile.description = data.description
|
profile.description = data.description
|
||||||
profile.language = data.language
|
profile.language = data.language
|
||||||
profile.updated_at = datetime.utcnow()
|
profile.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(profile)
|
db.refresh(profile)
|
||||||
|
|
||||||
return VoiceProfileResponse.model_validate(profile)
|
return VoiceProfileResponse.model_validate(profile)
|
||||||
|
|
||||||
|
|
||||||
@@ -327,6 +344,7 @@ async def create_voice_prompt_for_profile(
|
|||||||
profile_id: str,
|
profile_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
use_cache: bool = True,
|
use_cache: bool = True,
|
||||||
|
engine: str = "qwen",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Create a combined voice prompt from all samples in a profile.
|
Create a combined voice prompt from all samples in a profile.
|
||||||
@@ -335,17 +353,20 @@ async def create_voice_prompt_for_profile(
|
|||||||
profile_id: Profile ID
|
profile_id: Profile ID
|
||||||
db: Database session
|
db: Database session
|
||||||
use_cache: Whether to use cached prompts
|
use_cache: Whether to use cached prompts
|
||||||
|
engine: TTS engine to create prompt for ("qwen" or "luxtts")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Voice prompt dictionary
|
Voice prompt dictionary
|
||||||
"""
|
"""
|
||||||
|
from .backends import get_tts_backend_for_engine
|
||||||
|
|
||||||
# Get all samples for profile
|
# Get all samples for profile
|
||||||
samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all()
|
samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all()
|
||||||
|
|
||||||
if not samples:
|
if not samples:
|
||||||
raise ValueError(f"No samples found for profile {profile_id}")
|
raise ValueError(f"No samples found for profile {profile_id}")
|
||||||
|
|
||||||
tts_model = get_tts_model()
|
tts_model = get_tts_backend_for_engine(engine)
|
||||||
|
|
||||||
if len(samples) == 1:
|
if len(samples) == 1:
|
||||||
# Single sample - use directly
|
# Single sample - use directly
|
||||||
|
|||||||
@@ -9,15 +9,38 @@ alembic>=1.13.0
|
|||||||
|
|
||||||
# ML models
|
# ML models
|
||||||
torch>=2.1.0
|
torch>=2.1.0
|
||||||
transformers>=4.36.0
|
transformers>=4.36.0,<=4.57.6
|
||||||
accelerate>=0.26.0
|
accelerate>=0.26.0
|
||||||
huggingface_hub>=0.20.0
|
huggingface_hub>=0.20.0
|
||||||
qwen-tts>=0.0.5
|
qwen-tts>=0.0.5
|
||||||
|
|
||||||
|
# LuxTTS (voice cloning engine)
|
||||||
|
# piper-phonemize needs custom index (no PyPI wheels)
|
||||||
|
--find-links https://k2-fsa.github.io/icefall/piper_phonemize.html
|
||||||
|
# linacodec is a git-only dep of Zipvoice (uv-only source, pip can't resolve it)
|
||||||
|
linacodec @ git+https://github.com/ysharma3501/LinaCodec.git
|
||||||
|
Zipvoice @ git+https://github.com/ysharma3501/LuxTTS.git
|
||||||
|
|
||||||
|
# Chatterbox TTS sub-dependencies (chatterbox-tts itself is installed
|
||||||
|
# --no-deps in the setup script because it pins numpy<1.26 / torch==2.6
|
||||||
|
# which are incompatible with Python 3.12+)
|
||||||
|
conformer>=0.3.2
|
||||||
|
diffusers>=0.29.0
|
||||||
|
omegaconf
|
||||||
|
pykakasi
|
||||||
|
resemble-perth>=1.0.1
|
||||||
|
s3tokenizer
|
||||||
|
spacy-pkuseg
|
||||||
|
pyloudnorm
|
||||||
|
|
||||||
# Audio processing
|
# Audio processing
|
||||||
librosa>=0.10.0
|
librosa>=0.10.0
|
||||||
soundfile>=0.12.0
|
soundfile>=0.12.0
|
||||||
numpy>=1.24.0
|
numpy>=1.24.0
|
||||||
|
numba>=0.60.0,<0.61.0
|
||||||
|
|
||||||
|
# HTTP client (for CUDA backend download)
|
||||||
|
httpx>=0.27.0
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
|
|||||||
@@ -64,7 +64,29 @@ if __name__ == "__main__":
|
|||||||
default=None,
|
default=None,
|
||||||
help="Data directory for database, profiles, and generated audio",
|
help="Data directory for database, profiles, and generated audio",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--version",
|
||||||
|
action="store_true",
|
||||||
|
help="Print version and exit",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.version:
|
||||||
|
from backend import __version__
|
||||||
|
print(f"voicebox-server {__version__}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Detect backend variant from binary name
|
||||||
|
# voicebox-server-cuda → sets VOICEBOX_BACKEND_VARIANT=cuda
|
||||||
|
import os
|
||||||
|
binary_name = os.path.basename(sys.executable).lower()
|
||||||
|
if "cuda" in binary_name:
|
||||||
|
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cuda"
|
||||||
|
logger.info("Backend variant: CUDA")
|
||||||
|
else:
|
||||||
|
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cpu"
|
||||||
|
logger.info("Backend variant: CPU")
|
||||||
|
|
||||||
logger.info(f"Parsed arguments: host={args.host}, port={args.port}, data_dir={args.data_dir}")
|
logger.info(f"Parsed arguments: host={args.host}, port={args.port}, data_dir={args.data_dir}")
|
||||||
|
|
||||||
# Set data directory if provided
|
# Set data directory if provided
|
||||||
|
|||||||
+6
-6
@@ -270,11 +270,14 @@ async def add_item_to_story(
|
|||||||
generation_created_at=generation.created_at,
|
generation_created_at=generation.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get track from data or default to 0
|
||||||
|
track = data.track if data.track is not None else 0
|
||||||
|
|
||||||
# Calculate start_time_ms if not provided
|
# Calculate start_time_ms if not provided
|
||||||
if data.start_time_ms is not None:
|
if data.start_time_ms is not None:
|
||||||
start_time_ms = data.start_time_ms
|
start_time_ms = data.start_time_ms
|
||||||
else:
|
else:
|
||||||
# Find the maximum end time (start_time_ms + duration_ms) of existing items
|
# Find the maximum end time on the target track only
|
||||||
existing_items = db.query(
|
existing_items = db.query(
|
||||||
DBStoryItem,
|
DBStoryItem,
|
||||||
DBGeneration
|
DBGeneration
|
||||||
@@ -282,11 +285,11 @@ async def add_item_to_story(
|
|||||||
DBGeneration,
|
DBGeneration,
|
||||||
DBStoryItem.generation_id == DBGeneration.id
|
DBStoryItem.generation_id == DBGeneration.id
|
||||||
).filter(
|
).filter(
|
||||||
DBStoryItem.story_id == story_id
|
DBStoryItem.story_id == story_id,
|
||||||
|
DBStoryItem.track == track,
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
if not existing_items:
|
if not existing_items:
|
||||||
# First item starts at 0
|
|
||||||
start_time_ms = 0
|
start_time_ms = 0
|
||||||
else:
|
else:
|
||||||
max_end_time_ms = 0
|
max_end_time_ms = 0
|
||||||
@@ -297,9 +300,6 @@ async def add_item_to_story(
|
|||||||
# Add 200ms gap after the last item
|
# Add 200ms gap after the last item
|
||||||
start_time_ms = max_end_time_ms + 200
|
start_time_ms = max_end_time_ms + 200
|
||||||
|
|
||||||
# Get track from data or default to 0
|
|
||||||
track = data.track if data.track is not None else 0
|
|
||||||
|
|
||||||
# Create item
|
# Create item
|
||||||
item = DBStoryItem(
|
item = DBStoryItem(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
|
|||||||
@@ -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,162 @@
|
|||||||
|
"""
|
||||||
|
Tests for CORS origin restrictions.
|
||||||
|
|
||||||
|
Validates that the CORS middleware only allows known local origins
|
||||||
|
and respects the VOICEBOX_CORS_ORIGINS environment variable.
|
||||||
|
|
||||||
|
Uses a minimal FastAPI app that mirrors the exact CORS configuration
|
||||||
|
from backend/main.py, so tests run without heavy ML dependencies.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
pip install httpx pytest fastapi starlette
|
||||||
|
python -m pytest backend/tests/test_cors.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _build_app(env_origins: str = "") -> FastAPI:
|
||||||
|
"""
|
||||||
|
Build a minimal FastAPI app with the same CORS logic as backend/main.py.
|
||||||
|
|
||||||
|
This mirrors the exact code in main.py so the test validates the real
|
||||||
|
configuration without needing torch/numpy/transformers installed.
|
||||||
|
"""
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
_default_origins = [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://localhost:17493",
|
||||||
|
"http://127.0.0.1:17493",
|
||||||
|
"tauri://localhost",
|
||||||
|
"https://tauri.localhost",
|
||||||
|
]
|
||||||
|
_cors_origins = _default_origins + [o.strip() for o in env_origins.split(",") if o.strip()]
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=_cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
return TestClient(_build_app())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client_with_custom_origins():
|
||||||
|
return TestClient(_build_app("https://custom.example.com,https://other.example.com"))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_with_origin(client: TestClient, origin: str) -> dict:
|
||||||
|
"""Send a GET with Origin header, return response headers."""
|
||||||
|
response = client.get("/health", headers={"Origin": origin})
|
||||||
|
return dict(response.headers)
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight(client: TestClient, origin: str) -> dict:
|
||||||
|
"""Send CORS preflight OPTIONS request, return response headers."""
|
||||||
|
response = client.options(
|
||||||
|
"/health",
|
||||||
|
headers={
|
||||||
|
"Origin": origin,
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return dict(response.headers)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCORSDefaultOrigins:
|
||||||
|
"""CORS should allow known local origins and block everything else."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("origin", [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://localhost:17493",
|
||||||
|
"http://127.0.0.1:17493",
|
||||||
|
"tauri://localhost",
|
||||||
|
"https://tauri.localhost",
|
||||||
|
])
|
||||||
|
def test_allowed_origins(self, client, origin):
|
||||||
|
headers = _get_with_origin(client, origin)
|
||||||
|
assert headers.get("access-control-allow-origin") == origin
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("origin", [
|
||||||
|
"http://evil.com",
|
||||||
|
"http://localhost:9999",
|
||||||
|
"https://attacker.example.com",
|
||||||
|
"null",
|
||||||
|
])
|
||||||
|
def test_blocked_origins(self, client, origin):
|
||||||
|
headers = _get_with_origin(client, origin)
|
||||||
|
assert "access-control-allow-origin" not in headers
|
||||||
|
|
||||||
|
def test_preflight_allowed(self, client):
|
||||||
|
headers = _preflight(client, "http://localhost:5173")
|
||||||
|
assert headers.get("access-control-allow-origin") == "http://localhost:5173"
|
||||||
|
|
||||||
|
def test_preflight_blocked(self, client):
|
||||||
|
headers = _preflight(client, "http://evil.com")
|
||||||
|
assert "access-control-allow-origin" not in headers
|
||||||
|
|
||||||
|
def test_credentials_header_present(self, client):
|
||||||
|
headers = _get_with_origin(client, "http://localhost:5173")
|
||||||
|
assert headers.get("access-control-allow-credentials") == "true"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCORSCustomOrigins:
|
||||||
|
"""VOICEBOX_CORS_ORIGINS env var should extend the allowlist."""
|
||||||
|
|
||||||
|
def test_custom_origin_allowed(self, client_with_custom_origins):
|
||||||
|
headers = _get_with_origin(client_with_custom_origins, "https://custom.example.com")
|
||||||
|
assert headers.get("access-control-allow-origin") == "https://custom.example.com"
|
||||||
|
|
||||||
|
def test_other_custom_origin_allowed(self, client_with_custom_origins):
|
||||||
|
headers = _get_with_origin(client_with_custom_origins, "https://other.example.com")
|
||||||
|
assert headers.get("access-control-allow-origin") == "https://other.example.com"
|
||||||
|
|
||||||
|
def test_default_origins_still_work(self, client_with_custom_origins):
|
||||||
|
headers = _get_with_origin(client_with_custom_origins, "http://localhost:5173")
|
||||||
|
assert headers.get("access-control-allow-origin") == "http://localhost:5173"
|
||||||
|
|
||||||
|
def test_unlisted_origin_still_blocked(self, client_with_custom_origins):
|
||||||
|
headers = _get_with_origin(client_with_custom_origins, "http://evil.com")
|
||||||
|
assert "access-control-allow-origin" not in headers
|
||||||
|
|
||||||
|
|
||||||
|
class TestCORSEnvVarParsing:
|
||||||
|
"""Edge cases for VOICEBOX_CORS_ORIGINS parsing."""
|
||||||
|
|
||||||
|
def test_empty_env_var(self):
|
||||||
|
app = _build_app("")
|
||||||
|
client = TestClient(app)
|
||||||
|
headers = _get_with_origin(client, "http://evil.com")
|
||||||
|
assert "access-control-allow-origin" not in headers
|
||||||
|
|
||||||
|
def test_whitespace_trimmed(self):
|
||||||
|
app = _build_app(" https://spaced.example.com ")
|
||||||
|
client = TestClient(app)
|
||||||
|
headers = _get_with_origin(client, "https://spaced.example.com")
|
||||||
|
assert headers.get("access-control-allow-origin") == "https://spaced.example.com"
|
||||||
|
|
||||||
|
def test_trailing_comma_ignored(self):
|
||||||
|
app = _build_app("https://one.example.com,")
|
||||||
|
client = TestClient(app)
|
||||||
|
headers = _get_with_origin(client, "https://one.example.com")
|
||||||
|
assert headers.get("access-control-allow-origin") == "https://one.example.com"
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"""
|
||||||
|
Test TTS generation with SSE progress monitoring.
|
||||||
|
This test captures the exact SSE events triggered during generation
|
||||||
|
to identify UX issues where users see download progress even when
|
||||||
|
the model is already cached.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
async def monitor_sse_stream(model_name: str, timeout: int = 120):
|
||||||
|
"""Monitor SSE stream for a model during generation."""
|
||||||
|
events: List[Dict] = []
|
||||||
|
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||||
|
|
||||||
|
print(f"[{_timestamp()}] Connecting to SSE endpoint: {url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
async with client.stream("GET", url) as response:
|
||||||
|
print(f"[{_timestamp()}] SSE connected, status: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"[{_timestamp()}] Error: SSE endpoint returned {response.status_code}")
|
||||||
|
return events
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
timestamp = _timestamp()
|
||||||
|
|
||||||
|
if line.startswith("data: "):
|
||||||
|
try:
|
||||||
|
data = json.loads(line[6:])
|
||||||
|
print(f"[{timestamp}] → SSE Event: {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
|
||||||
|
events.append({
|
||||||
|
**data,
|
||||||
|
"_timestamp": timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
# Stop if complete or error
|
||||||
|
if data.get("status") in ("complete", "error"):
|
||||||
|
print(f"[{timestamp}] → Model {data['status']}!")
|
||||||
|
break
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"[{timestamp}] Error parsing JSON: {e}")
|
||||||
|
print(f" Line was: {line}")
|
||||||
|
|
||||||
|
elif line.startswith(": heartbeat"):
|
||||||
|
print(f"[{timestamp}] ♥ heartbeat")
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(f"[{_timestamp()}] SSE monitoring timed out")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{_timestamp()}] SSE error: {e}")
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def trigger_generation(profile_id: str, text: str, model_size: str = "1.7B"):
|
||||||
|
"""Trigger TTS generation via the API."""
|
||||||
|
url = "http://localhost:8000/generate"
|
||||||
|
|
||||||
|
print(f"\n[{_timestamp()}] Triggering generation...")
|
||||||
|
print(f" Profile: {profile_id}")
|
||||||
|
print(f" Text: {text[:50]}...")
|
||||||
|
print(f" Model: {model_size}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
response = await client.post(url, json={
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"text": text,
|
||||||
|
"language": "en",
|
||||||
|
"model_size": model_size,
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"[{_timestamp()}] Response: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
print(f"[{_timestamp()}] ✓ Generation successful!")
|
||||||
|
print(f" Generation ID: {result.get('id')}")
|
||||||
|
print(f" Duration: {result.get('duration', 0):.2f}s")
|
||||||
|
return True, result
|
||||||
|
elif response.status_code == 202:
|
||||||
|
# Model is being downloaded
|
||||||
|
result = response.json()
|
||||||
|
print(f"[{_timestamp()}] → Model download in progress")
|
||||||
|
print(f" Detail: {result}")
|
||||||
|
return False, result
|
||||||
|
else:
|
||||||
|
print(f"[{_timestamp()}] ✗ Error: {response.text}")
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{_timestamp()}] ✗ Exception: {e}")
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_first_profile():
|
||||||
|
"""Get the first available voice profile."""
|
||||||
|
url = "http://localhost:8000/profiles"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
if response.status_code == 200:
|
||||||
|
profiles = response.json()
|
||||||
|
if profiles:
|
||||||
|
return profiles[0]["id"]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting profiles: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def check_server():
|
||||||
|
"""Check if the server is running."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
response = await client.get("http://localhost:8000/health")
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Server not running: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp():
|
||||||
|
"""Get current timestamp for logging."""
|
||||||
|
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generation_with_cached_model():
|
||||||
|
"""
|
||||||
|
Test Case 1: Generation when model is already cached.
|
||||||
|
|
||||||
|
This should NOT show any download progress events.
|
||||||
|
If it does, that's the UX bug we're trying to fix.
|
||||||
|
"""
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("TEST CASE 1: Generation with Cached Model")
|
||||||
|
print("=" * 80)
|
||||||
|
print("Expected: No download progress events (or minimal/instant completion)")
|
||||||
|
print("Actual UX Issue: Users see 'started' and 'finished' events even for cached models")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
model_size = "1.7B"
|
||||||
|
model_name = f"qwen-tts-{model_size}"
|
||||||
|
|
||||||
|
# Get a profile
|
||||||
|
profile_id = await get_first_profile()
|
||||||
|
if not profile_id:
|
||||||
|
print("✗ No voice profiles found. Please create a profile first.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"\nUsing profile: {profile_id}")
|
||||||
|
|
||||||
|
# Start SSE monitor BEFORE triggering generation
|
||||||
|
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=30))
|
||||||
|
|
||||||
|
# Wait for SSE to connect
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Trigger generation
|
||||||
|
test_text = "Hello, this is a test of the voice generation system."
|
||||||
|
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||||
|
|
||||||
|
if not success and result and result.get("downloading"):
|
||||||
|
print("\n⚠ Model is being downloaded. Waiting for download to complete...")
|
||||||
|
# Wait for SSE monitor to capture download events
|
||||||
|
events = await monitor_task
|
||||||
|
return events
|
||||||
|
|
||||||
|
# Wait a bit more to catch any progress events
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
# Cancel SSE monitor
|
||||||
|
monitor_task.cancel()
|
||||||
|
try:
|
||||||
|
events = await monitor_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
events = []
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generation_with_fresh_download():
|
||||||
|
"""
|
||||||
|
Test Case 2: Generation when model needs to be downloaded.
|
||||||
|
|
||||||
|
This SHOULD show download progress events.
|
||||||
|
"""
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("TEST CASE 2: Generation with Model Download")
|
||||||
|
print("=" * 80)
|
||||||
|
print("Expected: Download progress events from 0% to 100%")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
# Use a different model size to force download
|
||||||
|
model_size = "0.6B" # Smaller model for faster testing
|
||||||
|
model_name = f"qwen-tts-{model_size}"
|
||||||
|
|
||||||
|
# Get a profile
|
||||||
|
profile_id = await get_first_profile()
|
||||||
|
if not profile_id:
|
||||||
|
print("✗ No voice profiles found. Please create a profile first.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"\nUsing profile: {profile_id}")
|
||||||
|
print("Note: This will download the model if not cached")
|
||||||
|
|
||||||
|
# Start SSE monitor BEFORE triggering generation
|
||||||
|
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=300))
|
||||||
|
|
||||||
|
# Wait for SSE to connect
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Trigger generation
|
||||||
|
test_text = "This should trigger a model download if the model is not cached."
|
||||||
|
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||||
|
|
||||||
|
if not success and result and result.get("downloading"):
|
||||||
|
print("\n→ Model download initiated. Monitoring progress...")
|
||||||
|
# Wait for download to complete
|
||||||
|
events = await monitor_task
|
||||||
|
|
||||||
|
# Try generation again
|
||||||
|
print(f"\n[{_timestamp()}] Retrying generation after download...")
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("✓ Generation successful after download")
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
# If model was already cached
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
monitor_task.cancel()
|
||||||
|
try:
|
||||||
|
events = await monitor_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
events = []
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=" * 80)
|
||||||
|
print("TTS Generation Progress Test")
|
||||||
|
print("=" * 80)
|
||||||
|
print("Purpose: Capture exact SSE events during generation to identify UX issues")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
# Check if server is running
|
||||||
|
print(f"\n[{_timestamp()}] Checking if server is running...")
|
||||||
|
if not await check_server():
|
||||||
|
print("✗ Server is not running on http://localhost:8000")
|
||||||
|
print("\nPlease start the server first:")
|
||||||
|
print(" cd backend && python main.py")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("✓ Server is running")
|
||||||
|
|
||||||
|
# Test Case 1: Cached model
|
||||||
|
print("\n" + "🧪 " * 20)
|
||||||
|
events_cached = await test_generation_with_cached_model()
|
||||||
|
|
||||||
|
# Results for Test Case 1
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("TEST CASE 1 RESULTS: Generation with Cached Model")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
if not events_cached:
|
||||||
|
print("✓ GOOD: No SSE progress events received")
|
||||||
|
print(" This is the expected behavior for a cached model.")
|
||||||
|
else:
|
||||||
|
print(f"⚠ ISSUE FOUND: Received {len(events_cached)} SSE events:")
|
||||||
|
print("\nEvent Timeline:")
|
||||||
|
for i, event in enumerate(events_cached, 1):
|
||||||
|
timestamp = event.pop("_timestamp", "??:??:??.???")
|
||||||
|
print(f" {i}. [{timestamp}] {event}")
|
||||||
|
|
||||||
|
print("\n⚠ This explains the UX issue!")
|
||||||
|
print(" Users see progress events even when the model is already cached,")
|
||||||
|
print(" making them think the model is downloading again.")
|
||||||
|
|
||||||
|
# Test Case 2: Fresh download (optional, commented out by default)
|
||||||
|
# Uncomment if you want to test download progress
|
||||||
|
# print("\n" + "🧪 " * 20)
|
||||||
|
# events_download = await test_generation_with_fresh_download()
|
||||||
|
#
|
||||||
|
# print("\n" + "=" * 80)
|
||||||
|
# print("TEST CASE 2 RESULTS: Generation with Model Download")
|
||||||
|
# print("=" * 80)
|
||||||
|
#
|
||||||
|
# if not events_download:
|
||||||
|
# print("ℹ Model was already cached, no download occurred")
|
||||||
|
# else:
|
||||||
|
# print(f"✓ Received {len(events_download)} download progress events")
|
||||||
|
# print("\nDownload Timeline:")
|
||||||
|
# for i, event in enumerate(events_download, 1):
|
||||||
|
# timestamp = event.pop("_timestamp", "??:??:??.???")
|
||||||
|
# print(f" {i}. [{timestamp}] {event}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("Test Complete!")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""
|
||||||
|
Tests for profile duplicate name validation.
|
||||||
|
|
||||||
|
This test suite verifies that the application correctly handles
|
||||||
|
duplicate profile names and provides user-friendly error messages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
# Add parent directory to path to import backend modules
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from database import Base, VoiceProfile as DBVoiceProfile
|
||||||
|
from models import VoiceProfileCreate
|
||||||
|
from profiles import create_profile, update_profile
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_db():
|
||||||
|
"""Create a temporary test database."""
|
||||||
|
# Create temporary directory for test database
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
db_path = Path(temp_dir) / "test.db"
|
||||||
|
|
||||||
|
# Create engine and session
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
yield db
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
db.close()
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_profiles_dir(monkeypatch, tmp_path):
|
||||||
|
"""Mock the profiles directory to use a temporary path."""
|
||||||
|
import profiles
|
||||||
|
monkeypatch.setattr(profiles, '_get_profiles_dir', lambda: tmp_path)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_profile_duplicate_name_raises_error(test_db, mock_profiles_dir):
|
||||||
|
"""Test that creating a profile with a duplicate name raises a ValueError."""
|
||||||
|
# Create first profile
|
||||||
|
profile_data_1 = VoiceProfileCreate(
|
||||||
|
name="Test Profile",
|
||||||
|
description="First profile",
|
||||||
|
language="en"
|
||||||
|
)
|
||||||
|
|
||||||
|
profile_1 = await create_profile(profile_data_1, test_db)
|
||||||
|
assert profile_1.name == "Test Profile"
|
||||||
|
|
||||||
|
# Try to create second profile with same name
|
||||||
|
profile_data_2 = VoiceProfileCreate(
|
||||||
|
name="Test Profile",
|
||||||
|
description="Second profile",
|
||||||
|
language="en"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
await create_profile(profile_data_2, test_db)
|
||||||
|
|
||||||
|
# Verify error message is user-friendly
|
||||||
|
assert "already exists" in str(exc_info.value)
|
||||||
|
assert "Test Profile" in str(exc_info.value)
|
||||||
|
assert "choose a different name" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_profile_different_names_succeeds(test_db, mock_profiles_dir):
|
||||||
|
"""Test that creating profiles with different names succeeds."""
|
||||||
|
# Create first profile
|
||||||
|
profile_data_1 = VoiceProfileCreate(
|
||||||
|
name="Profile One",
|
||||||
|
description="First profile",
|
||||||
|
language="en"
|
||||||
|
)
|
||||||
|
|
||||||
|
profile_1 = await create_profile(profile_data_1, test_db)
|
||||||
|
assert profile_1.name == "Profile One"
|
||||||
|
|
||||||
|
# Create second profile with different name
|
||||||
|
profile_data_2 = VoiceProfileCreate(
|
||||||
|
name="Profile Two",
|
||||||
|
description="Second profile",
|
||||||
|
language="en"
|
||||||
|
)
|
||||||
|
|
||||||
|
profile_2 = await create_profile(profile_data_2, test_db)
|
||||||
|
assert profile_2.name == "Profile Two"
|
||||||
|
|
||||||
|
# Verify both profiles exist
|
||||||
|
assert profile_1.id != profile_2.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_profile_to_duplicate_name_raises_error(test_db, mock_profiles_dir):
|
||||||
|
"""Test that updating a profile to a duplicate name raises a ValueError."""
|
||||||
|
# Create two profiles with different names
|
||||||
|
profile_data_1 = VoiceProfileCreate(
|
||||||
|
name="Profile A",
|
||||||
|
description="First profile",
|
||||||
|
language="en"
|
||||||
|
)
|
||||||
|
profile_1 = await create_profile(profile_data_1, test_db)
|
||||||
|
|
||||||
|
profile_data_2 = VoiceProfileCreate(
|
||||||
|
name="Profile B",
|
||||||
|
description="Second profile",
|
||||||
|
language="en"
|
||||||
|
)
|
||||||
|
profile_2 = await create_profile(profile_data_2, test_db)
|
||||||
|
|
||||||
|
# Try to update profile_2 to use profile_1's name
|
||||||
|
update_data = VoiceProfileCreate(
|
||||||
|
name="Profile A", # Duplicate name
|
||||||
|
description="Updated description",
|
||||||
|
language="en"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
await update_profile(profile_2.id, update_data, test_db)
|
||||||
|
|
||||||
|
# Verify error message is user-friendly
|
||||||
|
assert "already exists" in str(exc_info.value)
|
||||||
|
assert "Profile A" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_profile_keep_same_name_succeeds(test_db, mock_profiles_dir):
|
||||||
|
"""Test that updating a profile while keeping the same name succeeds."""
|
||||||
|
# Create profile
|
||||||
|
profile_data = VoiceProfileCreate(
|
||||||
|
name="My Profile",
|
||||||
|
description="Original description",
|
||||||
|
language="en"
|
||||||
|
)
|
||||||
|
profile = await create_profile(profile_data, test_db)
|
||||||
|
|
||||||
|
# Update profile with same name but different description
|
||||||
|
update_data = VoiceProfileCreate(
|
||||||
|
name="My Profile", # Same name
|
||||||
|
description="Updated description",
|
||||||
|
language="en"
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_profile = await update_profile(profile.id, update_data, test_db)
|
||||||
|
|
||||||
|
# Verify update succeeded
|
||||||
|
assert updated_profile is not None
|
||||||
|
assert updated_profile.id == profile.id
|
||||||
|
assert updated_profile.name == "My Profile"
|
||||||
|
assert updated_profile.description == "Updated description"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_profile_to_new_unique_name_succeeds(test_db, mock_profiles_dir):
|
||||||
|
"""Test that updating a profile to a new unique name succeeds."""
|
||||||
|
# Create profile
|
||||||
|
profile_data = VoiceProfileCreate(
|
||||||
|
name="Original Name",
|
||||||
|
description="Profile description",
|
||||||
|
language="en"
|
||||||
|
)
|
||||||
|
profile = await create_profile(profile_data, test_db)
|
||||||
|
|
||||||
|
# Update profile with new unique name
|
||||||
|
update_data = VoiceProfileCreate(
|
||||||
|
name="New Unique Name",
|
||||||
|
description="Updated description",
|
||||||
|
language="en"
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_profile = await update_profile(profile.id, update_data, test_db)
|
||||||
|
|
||||||
|
# Verify update succeeded
|
||||||
|
assert updated_profile is not None
|
||||||
|
assert updated_profile.id == profile.id
|
||||||
|
assert updated_profile.name == "New Unique Name"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_case_sensitive_names_allowed(test_db, mock_profiles_dir):
|
||||||
|
"""Test that profile names are case-sensitive (e.g., 'Test' and 'test' are different)."""
|
||||||
|
# Create profile with lowercase name
|
||||||
|
profile_data_1 = VoiceProfileCreate(
|
||||||
|
name="test profile",
|
||||||
|
description="Lowercase",
|
||||||
|
language="en"
|
||||||
|
)
|
||||||
|
profile_1 = await create_profile(profile_data_1, test_db)
|
||||||
|
|
||||||
|
# Create profile with different case
|
||||||
|
profile_data_2 = VoiceProfileCreate(
|
||||||
|
name="Test Profile",
|
||||||
|
description="Title case",
|
||||||
|
language="en"
|
||||||
|
)
|
||||||
|
profile_2 = await create_profile(profile_data_2, test_db)
|
||||||
|
|
||||||
|
# Both should succeed since SQLite unique constraint is case-sensitive by default
|
||||||
|
assert profile_1.name == "test profile"
|
||||||
|
assert profile_2.name == "Test Profile"
|
||||||
|
assert profile_1.id != profile_2.id
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""
|
||||||
|
Test script to debug model download progress tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import List, Dict
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Set up logging to see what's happening
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
from utils.progress import ProgressManager, get_progress_manager
|
||||||
|
from utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_manager_basic():
|
||||||
|
"""Test 1: Basic ProgressManager functionality."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Test 1: ProgressManager Basic Operations")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
pm = ProgressManager()
|
||||||
|
|
||||||
|
# Test update_progress
|
||||||
|
pm.update_progress(
|
||||||
|
model_name="test-model",
|
||||||
|
current=50,
|
||||||
|
total=100,
|
||||||
|
filename="test.bin",
|
||||||
|
status="downloading"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test get_progress
|
||||||
|
progress = pm.get_progress("test-model")
|
||||||
|
print(f"✓ Progress stored: {progress}")
|
||||||
|
assert progress is not None
|
||||||
|
assert progress["progress"] == 50.0
|
||||||
|
assert progress["filename"] == "test.bin"
|
||||||
|
assert progress["status"] == "downloading"
|
||||||
|
|
||||||
|
# Test mark_complete
|
||||||
|
pm.mark_complete("test-model")
|
||||||
|
progress = pm.get_progress("test-model")
|
||||||
|
print(f"✓ Marked complete: {progress}")
|
||||||
|
assert progress["status"] == "complete"
|
||||||
|
assert progress["progress"] == 100.0
|
||||||
|
|
||||||
|
print("✓ Test 1 PASSED\n")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_progress_manager_sse():
|
||||||
|
"""Test 2: ProgressManager SSE streaming."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Test 2: ProgressManager SSE Streaming")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
pm = ProgressManager()
|
||||||
|
collected_events: List[Dict] = []
|
||||||
|
|
||||||
|
# Simulate SSE client
|
||||||
|
async def sse_client():
|
||||||
|
"""Simulates a frontend SSE connection."""
|
||||||
|
print(" SSE client: Subscribing to test-model-sse...")
|
||||||
|
async for event in pm.subscribe("test-model-sse"):
|
||||||
|
# Parse SSE event
|
||||||
|
if event.startswith("data: "):
|
||||||
|
data = json.loads(event[6:])
|
||||||
|
print(f" SSE client: Received event: {data['status']} - {data.get('progress', 0):.1f}%")
|
||||||
|
collected_events.append(data)
|
||||||
|
|
||||||
|
# Stop when complete
|
||||||
|
if data.get("status") in ("complete", "error"):
|
||||||
|
break
|
||||||
|
elif event.startswith(": heartbeat"):
|
||||||
|
print(" SSE client: Received heartbeat")
|
||||||
|
|
||||||
|
# Simulate download progress updates (from backend thread)
|
||||||
|
async def simulate_download():
|
||||||
|
"""Simulates backend sending progress updates."""
|
||||||
|
print(" Backend: Starting simulated download...")
|
||||||
|
await asyncio.sleep(0.2) # Let SSE client subscribe first
|
||||||
|
|
||||||
|
# Send progress updates
|
||||||
|
for i in range(0, 101, 20):
|
||||||
|
print(f" Backend: Updating progress to {i}%")
|
||||||
|
pm.update_progress(
|
||||||
|
model_name="test-model-sse",
|
||||||
|
current=i,
|
||||||
|
total=100,
|
||||||
|
filename=f"file_{i}.bin",
|
||||||
|
status="downloading" if i < 100 else "downloading"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# Mark complete
|
||||||
|
print(" Backend: Marking download complete")
|
||||||
|
pm.mark_complete("test-model-sse")
|
||||||
|
|
||||||
|
# Run SSE client and download simulation concurrently
|
||||||
|
await asyncio.gather(
|
||||||
|
sse_client(),
|
||||||
|
simulate_download()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify we got events
|
||||||
|
print(f"\n Collected {len(collected_events)} events")
|
||||||
|
assert len(collected_events) > 0, "Should have received at least one event"
|
||||||
|
assert collected_events[-1]["status"] == "complete", "Last event should be 'complete'"
|
||||||
|
|
||||||
|
print("✓ Test 2 PASSED\n")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_hf_progress_tracker():
|
||||||
|
"""Test 3: HFProgressTracker tqdm patching."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Test 3: HFProgressTracker tqdm Patching")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
captured_progress: List[tuple] = []
|
||||||
|
|
||||||
|
def progress_callback(downloaded: int, total: int, filename: str):
|
||||||
|
"""Capture progress updates."""
|
||||||
|
captured_progress.append((downloaded, total, filename))
|
||||||
|
print(f" Progress callback: {downloaded}/{total} bytes ({filename})")
|
||||||
|
|
||||||
|
tracker = HFProgressTracker(progress_callback)
|
||||||
|
|
||||||
|
# Simulate a download with tqdm
|
||||||
|
with tracker.patch_download():
|
||||||
|
try:
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
# Simulate downloading a file
|
||||||
|
print(" Simulating download with tqdm...")
|
||||||
|
total_size = 1000
|
||||||
|
with tqdm(total=total_size, desc="model.bin", unit="B", unit_scale=True) as pbar:
|
||||||
|
for chunk in range(0, total_size, 100):
|
||||||
|
pbar.update(100)
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
print(f" Captured {len(captured_progress)} progress updates")
|
||||||
|
assert len(captured_progress) > 0, "Should have captured progress updates"
|
||||||
|
|
||||||
|
# Verify progress increases
|
||||||
|
last_downloaded = 0
|
||||||
|
for downloaded, total, filename in captured_progress:
|
||||||
|
assert downloaded >= last_downloaded, "Downloaded bytes should increase"
|
||||||
|
assert total == total_size, "Total should be consistent"
|
||||||
|
last_downloaded = downloaded
|
||||||
|
|
||||||
|
print("✓ Test 3 PASSED\n")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
print("✗ tqdm not available, skipping test\n")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_integration():
|
||||||
|
"""Test 4: Full integration test."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Test 4: Full Integration (ProgressManager + HFProgressTracker)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
pm = get_progress_manager()
|
||||||
|
collected_events: List[Dict] = []
|
||||||
|
|
||||||
|
# SSE client
|
||||||
|
async def sse_client():
|
||||||
|
print(" SSE client: Subscribing...")
|
||||||
|
async for event in pm.subscribe("integration-test"):
|
||||||
|
if event.startswith("data: "):
|
||||||
|
data = json.loads(event[6:])
|
||||||
|
print(f" SSE client: {data['status']} - {data.get('progress', 0):.1f}% - {data.get('filename', '')}")
|
||||||
|
collected_events.append(data)
|
||||||
|
if data.get("status") in ("complete", "error"):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Simulate backend download with HFProgressTracker
|
||||||
|
async def simulate_real_download():
|
||||||
|
await asyncio.sleep(0.2) # Let SSE subscribe
|
||||||
|
|
||||||
|
print(" Backend: Starting download with HFProgressTracker...")
|
||||||
|
|
||||||
|
# Set up tracking (like the real backend does)
|
||||||
|
progress_callback = create_hf_progress_callback("integration-test", pm)
|
||||||
|
tracker = HFProgressTracker(progress_callback)
|
||||||
|
|
||||||
|
# Initialize progress
|
||||||
|
pm.update_progress(
|
||||||
|
model_name="integration-test",
|
||||||
|
current=0,
|
||||||
|
total=1,
|
||||||
|
filename="",
|
||||||
|
status="downloading"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate download with tqdm patching
|
||||||
|
with tracker.patch_download():
|
||||||
|
try:
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
# Simulate multi-file download (like HuggingFace does)
|
||||||
|
files = [
|
||||||
|
("model.safetensors", 5000),
|
||||||
|
("config.json", 1000),
|
||||||
|
("tokenizer.json", 500),
|
||||||
|
]
|
||||||
|
|
||||||
|
for filename, size in files:
|
||||||
|
print(f" Backend: Downloading {filename}...")
|
||||||
|
with tqdm(total=size, desc=filename, unit="B") as pbar:
|
||||||
|
for chunk in range(0, size, 500):
|
||||||
|
chunk_size = min(500, size - chunk)
|
||||||
|
pbar.update(chunk_size)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# Mark complete
|
||||||
|
print(" Backend: Download complete")
|
||||||
|
pm.mark_complete("integration-test")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
print(" ✗ tqdm not available")
|
||||||
|
pm.mark_error("integration-test", "tqdm not available")
|
||||||
|
|
||||||
|
# Run both
|
||||||
|
await asyncio.gather(
|
||||||
|
sse_client(),
|
||||||
|
simulate_real_download()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
print(f"\n Collected {len(collected_events)} events")
|
||||||
|
if len(collected_events) > 0:
|
||||||
|
print(f" First event: {collected_events[0]}")
|
||||||
|
print(f" Last event: {collected_events[-1]}")
|
||||||
|
assert collected_events[-1]["status"] == "complete", "Should end with 'complete'"
|
||||||
|
print("✓ Test 4 PASSED\n")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("✗ Test 4 FAILED - No events received\n")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run all tests."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Voicebox Progress Tracking Test Suite")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Test 1: Basic operations
|
||||||
|
try:
|
||||||
|
results.append(("Basic Operations", test_progress_manager_basic()))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Test 1 FAILED: {e}\n")
|
||||||
|
results.append(("Basic Operations", False))
|
||||||
|
|
||||||
|
# Test 2: SSE streaming
|
||||||
|
try:
|
||||||
|
results.append(("SSE Streaming", await test_progress_manager_sse()))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Test 2 FAILED: {e}\n")
|
||||||
|
results.append(("SSE Streaming", False))
|
||||||
|
|
||||||
|
# Test 3: tqdm patching
|
||||||
|
try:
|
||||||
|
results.append(("tqdm Patching", test_hf_progress_tracker()))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Test 3 FAILED: {e}\n")
|
||||||
|
results.append(("tqdm Patching", False))
|
||||||
|
|
||||||
|
# Test 4: Full integration
|
||||||
|
try:
|
||||||
|
results.append(("Full Integration", await test_full_integration()))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Test 4 FAILED: {e}\n")
|
||||||
|
results.append(("Full Integration", False))
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Test Results Summary")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
for name, result in results:
|
||||||
|
status = "✓ PASS" if result else ("⊘ SKIP" if result is None else "✗ FAIL")
|
||||||
|
print(f" {status:8} {name}")
|
||||||
|
|
||||||
|
passed = sum(1 for _, r in results if r is True)
|
||||||
|
failed = sum(1 for _, r in results if r is False)
|
||||||
|
skipped = sum(1 for _, r in results if r is None)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f" Total: {len(results)} tests")
|
||||||
|
print(f" Passed: {passed}")
|
||||||
|
print(f" Failed: {failed}")
|
||||||
|
print(f" Skipped: {skipped}")
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
|
return failed == 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = asyncio.run(main())
|
||||||
|
exit(0 if success else 1)
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"""
|
||||||
|
Test Qwen TTS model download with SSE progress monitoring.
|
||||||
|
|
||||||
|
This specifically tests the MLX TTS backend download progress tracking,
|
||||||
|
which requires tqdm to be patched BEFORE mlx_audio is imported.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cd backend && python -m tests.test_qwen_download
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Server must be running: cd backend && python main.py
|
||||||
|
- Delete model first for fresh download test:
|
||||||
|
curl -X DELETE http://localhost:8000/models/qwen-tts-0.6B
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
import time
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
async def monitor_sse_stream(model_name: str, timeout: int = 600) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Monitor SSE stream for a model download.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_name: Name of the model to monitor
|
||||||
|
timeout: Maximum time to wait for download (seconds)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of SSE events received
|
||||||
|
"""
|
||||||
|
events: List[Dict] = []
|
||||||
|
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||||
|
last_progress = -1
|
||||||
|
|
||||||
|
print(f"\n📡 Connecting to SSE endpoint: {url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
async with client.stream("GET", url) as response:
|
||||||
|
print(f" SSE connected, status: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f" ❌ Error: SSE endpoint returned {response.status_code}")
|
||||||
|
return events
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("data: "):
|
||||||
|
try:
|
||||||
|
data = json.loads(line[6:])
|
||||||
|
events.append(data)
|
||||||
|
|
||||||
|
# Print progress (only when it changes significantly)
|
||||||
|
progress = data.get('progress', 0)
|
||||||
|
status = data.get('status', 'unknown')
|
||||||
|
filename = data.get('filename', '')
|
||||||
|
current = data.get('current', 0)
|
||||||
|
total = data.get('total', 0)
|
||||||
|
|
||||||
|
# Print every 5% change or status change
|
||||||
|
if abs(progress - last_progress) >= 5 or status in ('complete', 'error'):
|
||||||
|
current_mb = current / (1024 * 1024)
|
||||||
|
total_mb = total / (1024 * 1024)
|
||||||
|
print(f" 📊 {status:12} {progress:6.1f}% ({current_mb:.1f}MB / {total_mb:.1f}MB) {filename[:50]}")
|
||||||
|
last_progress = progress
|
||||||
|
|
||||||
|
# Stop if complete or error
|
||||||
|
if status in ("complete", "error"):
|
||||||
|
if status == "complete":
|
||||||
|
print(f" ✅ Download complete!")
|
||||||
|
else:
|
||||||
|
print(f" ❌ Download error: {data.get('error', 'unknown')}")
|
||||||
|
break
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f" ⚠️ Error parsing JSON: {e}")
|
||||||
|
|
||||||
|
elif line.startswith(": heartbeat"):
|
||||||
|
# Heartbeat every 1 second, don't spam
|
||||||
|
pass
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
print(" ⏹️ SSE monitor cancelled")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ SSE error: {e}")
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def trigger_download(model_name: str) -> bool:
|
||||||
|
"""Trigger a model download via the API."""
|
||||||
|
url = "http://localhost:8000/models/download"
|
||||||
|
|
||||||
|
print(f"\n🚀 Triggering download for: {model_name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(url, json={"model_name": model_name})
|
||||||
|
result = response.json()
|
||||||
|
print(f" Response: {response.status_code} - {result}")
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Error triggering download: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_model(model_name: str) -> bool:
|
||||||
|
"""Delete a model from cache."""
|
||||||
|
url = f"http://localhost:8000/models/{model_name}"
|
||||||
|
|
||||||
|
print(f"\n🗑️ Deleting model: {model_name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.delete(url)
|
||||||
|
if response.status_code == 200:
|
||||||
|
print(f" ✅ Model deleted")
|
||||||
|
return True
|
||||||
|
elif response.status_code == 404:
|
||||||
|
print(f" ℹ️ Model not found (already deleted)")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ Delete response: {response.status_code} - {response.text}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Error deleting model: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def check_model_status(model_name: str) -> Optional[Dict]:
|
||||||
|
"""Check the status of a model."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
response = await client.get("http://localhost:8000/models/status")
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
for model in data.get("models", []):
|
||||||
|
if model["model_name"] == model_name:
|
||||||
|
return model
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Error checking model status: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def check_server() -> bool:
|
||||||
|
"""Check if the server is running."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
response = await client.get("http://localhost:8000/health")
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=" * 70)
|
||||||
|
print("🧪 Qwen TTS Model Download Progress Test")
|
||||||
|
print("=" * 70)
|
||||||
|
print("\nThis test verifies that MLX TTS download progress tracking works.")
|
||||||
|
print("It specifically tests the tqdm patching for mlx_audio.tts imports.")
|
||||||
|
|
||||||
|
# Check if server is running
|
||||||
|
print("\n📡 Checking if server is running...")
|
||||||
|
if not await check_server():
|
||||||
|
print(" ❌ Server is not running on http://localhost:8000")
|
||||||
|
print("\n Please start the server first:")
|
||||||
|
print(" cd backend && python main.py")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(" ✅ Server is running")
|
||||||
|
|
||||||
|
# Test model
|
||||||
|
model_name = "qwen-tts-0.6B" # Note: 0.6B currently maps to 1.7B on MLX
|
||||||
|
|
||||||
|
# Check current status
|
||||||
|
print(f"\n📊 Checking status of {model_name}...")
|
||||||
|
status = await check_model_status(model_name)
|
||||||
|
if status:
|
||||||
|
print(f" Downloaded: {status.get('downloaded', False)}")
|
||||||
|
print(f" Downloading: {status.get('downloading', False)}")
|
||||||
|
print(f" Loaded: {status.get('loaded', False)}")
|
||||||
|
if status.get('size_mb'):
|
||||||
|
print(f" Size: {status['size_mb']:.1f} MB")
|
||||||
|
else:
|
||||||
|
print(" ⚠️ Could not get model status")
|
||||||
|
|
||||||
|
# Ask if user wants to delete first
|
||||||
|
print("\n" + "-" * 70)
|
||||||
|
if status and status.get('downloaded'):
|
||||||
|
print("⚠️ Model is already downloaded. Delete it for a fresh download test?")
|
||||||
|
print(" [y] Yes, delete and download fresh")
|
||||||
|
print(" [n] No, just test SSE connection")
|
||||||
|
print(" [q] Quit")
|
||||||
|
|
||||||
|
choice = input("\nChoice [y/n/q]: ").strip().lower()
|
||||||
|
|
||||||
|
if choice == 'q':
|
||||||
|
print("Exiting...")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if choice == 'y':
|
||||||
|
if not await delete_model(model_name):
|
||||||
|
print("Failed to delete model. Continue anyway? [y/n]")
|
||||||
|
if input().strip().lower() != 'y':
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print("Model not downloaded. Will perform fresh download test.")
|
||||||
|
input("Press Enter to continue...")
|
||||||
|
|
||||||
|
# Run the test
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("🏃 Starting Download Test")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
async def run_test():
|
||||||
|
# Start SSE monitor in background FIRST
|
||||||
|
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=600))
|
||||||
|
|
||||||
|
# Wait for SSE to connect
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Trigger download
|
||||||
|
success = await trigger_download(model_name)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print(" ❌ Failed to trigger download")
|
||||||
|
monitor_task.cancel()
|
||||||
|
try:
|
||||||
|
await monitor_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Wait for SSE monitor to complete
|
||||||
|
print("\n⏳ Waiting for download to complete (this may take several minutes)...")
|
||||||
|
events = await monitor_task
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
events = await run_test()
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
|
||||||
|
# Results
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("📋 Test Results")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(f"\n⏱️ Elapsed time: {elapsed:.1f} seconds")
|
||||||
|
print(f"📨 Total SSE events received: {len(events)}")
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
print("\n❌ FAILED - No SSE events received!")
|
||||||
|
print("\nPossible causes:")
|
||||||
|
print(" 1. SSE endpoint not working")
|
||||||
|
print(" 2. tqdm not patched before mlx_audio import")
|
||||||
|
print(" 3. Progress callbacks not firing")
|
||||||
|
print(" 4. Model already fully downloaded")
|
||||||
|
print("\nDebug steps:")
|
||||||
|
print(" 1. Check server logs for [DEBUG] messages")
|
||||||
|
print(" 2. Look for 'tqdm patched' before 'mlx_audio.tts import'")
|
||||||
|
print(f" 3. Delete model: curl -X DELETE http://localhost:8000/models/{model_name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Analyze events
|
||||||
|
first_event = events[0]
|
||||||
|
last_event = events[-1]
|
||||||
|
|
||||||
|
print(f"\n📊 First event:")
|
||||||
|
print(f" Status: {first_event.get('status')}")
|
||||||
|
print(f" Progress: {first_event.get('progress', 0):.1f}%")
|
||||||
|
|
||||||
|
print(f"\n📊 Last event:")
|
||||||
|
print(f" Status: {last_event.get('status')}")
|
||||||
|
print(f" Progress: {last_event.get('progress', 0):.1f}%")
|
||||||
|
|
||||||
|
# Check for expected behaviors
|
||||||
|
has_progress_updates = len(events) > 2
|
||||||
|
has_increasing_progress = False
|
||||||
|
has_complete = any(e.get('status') == 'complete' for e in events)
|
||||||
|
has_100_percent = any(e.get('progress', 0) >= 100 for e in events)
|
||||||
|
|
||||||
|
# Check if progress increased over time
|
||||||
|
if len(events) >= 2:
|
||||||
|
progress_values = [e.get('progress', 0) for e in events]
|
||||||
|
has_increasing_progress = progress_values[-1] > progress_values[0]
|
||||||
|
|
||||||
|
print("\n📋 Checks:")
|
||||||
|
print(f" {'✅' if has_progress_updates else '❌'} Multiple progress updates received ({len(events)} events)")
|
||||||
|
print(f" {'✅' if has_increasing_progress else '❌'} Progress increased over time")
|
||||||
|
print(f" {'✅' if has_100_percent else '❌'} Reached 100% progress")
|
||||||
|
print(f" {'✅' if has_complete else '❌'} Received 'complete' status")
|
||||||
|
|
||||||
|
# Overall result
|
||||||
|
success = has_progress_updates and has_complete
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("✅ TEST PASSED - Qwen TTS download progress tracking works!")
|
||||||
|
print("=" * 70)
|
||||||
|
else:
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("❌ TEST FAILED - Progress tracking has issues")
|
||||||
|
print("=" * 70)
|
||||||
|
print("\nCheck the server logs for debug output.")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
result = asyncio.run(main())
|
||||||
|
exit(0 if result else 1)
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
Test real model download with SSE progress monitoring.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
import time
|
||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
|
async def monitor_sse_stream(model_name: str, timeout: int = 300):
|
||||||
|
"""Monitor SSE stream for a model download."""
|
||||||
|
events: List[Dict] = []
|
||||||
|
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||||
|
|
||||||
|
print(f"Connecting to SSE endpoint: {url}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
async with client.stream("GET", url) as response:
|
||||||
|
print(f"SSE connected, status: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"Error: SSE endpoint returned {response.status_code}")
|
||||||
|
return events
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" Raw SSE: {line[:100]}...") # Print first 100 chars
|
||||||
|
|
||||||
|
if line.startswith("data: "):
|
||||||
|
try:
|
||||||
|
data = json.loads(line[6:])
|
||||||
|
print(f" → {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
|
||||||
|
events.append(data)
|
||||||
|
|
||||||
|
# Stop if complete or error
|
||||||
|
if data.get("status") in ("complete", "error"):
|
||||||
|
print(f" Download {data['status']}!")
|
||||||
|
break
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f" Error parsing JSON: {e}")
|
||||||
|
print(f" Line was: {line}")
|
||||||
|
|
||||||
|
elif line.startswith(": heartbeat"):
|
||||||
|
print(" ♥ heartbeat")
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def trigger_download(model_name: str):
|
||||||
|
"""Trigger a model download via the API."""
|
||||||
|
url = "http://localhost:8000/models/download"
|
||||||
|
|
||||||
|
print(f"\nTriggering download for: {model_name}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=300) as client:
|
||||||
|
response = await client.post(url, json={"model_name": model_name})
|
||||||
|
print(f"Response: {response.status_code} - {response.json()}")
|
||||||
|
return response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def check_server():
|
||||||
|
"""Check if the server is running."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
response = await client.get("http://localhost:8000/health")
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Server not running: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("Real Model Download Progress Test")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Check if server is running
|
||||||
|
print("\nChecking if server is running...")
|
||||||
|
if not await check_server():
|
||||||
|
print("✗ Server is not running on http://localhost:8000")
|
||||||
|
print("\nPlease start the server first:")
|
||||||
|
print(" cd backend && python main.py")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("✓ Server is running")
|
||||||
|
|
||||||
|
# Choose a small model for testing
|
||||||
|
model_name = "whisper-base" # ~150MB, faster to download
|
||||||
|
print(f"\nUsing model: {model_name}")
|
||||||
|
|
||||||
|
# Option to delete model first if it exists
|
||||||
|
print("\nDo you want to delete the model first to force a fresh download? (y/n)")
|
||||||
|
# For automated testing, skip deletion prompt
|
||||||
|
# delete_first = input().strip().lower() == 'y'
|
||||||
|
delete_first = False
|
||||||
|
|
||||||
|
if delete_first:
|
||||||
|
print(f"Deleting {model_name}...")
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.delete(f"http://localhost:8000/models/{model_name}")
|
||||||
|
print(f"Delete response: {response.status_code}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Starting Test")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Start monitoring SSE stream BEFORE triggering download
|
||||||
|
async def run_test():
|
||||||
|
# Start SSE monitor in background
|
||||||
|
monitor_task = asyncio.create_task(monitor_sse_stream(model_name))
|
||||||
|
|
||||||
|
# Wait a bit to ensure SSE is connected
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Trigger download
|
||||||
|
success = await trigger_download(model_name)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print("✗ Failed to trigger download")
|
||||||
|
monitor_task.cancel()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Wait for SSE monitor to complete
|
||||||
|
events = await monitor_task
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
events = await run_test()
|
||||||
|
|
||||||
|
# Results
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Test Results")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
print("✗ FAILED - No SSE events received!")
|
||||||
|
print("\nPossible causes:")
|
||||||
|
print(" 1. SSE endpoint not working")
|
||||||
|
print(" 2. Progress updates not being sent")
|
||||||
|
print(" 3. Model already downloaded (no progress to report)")
|
||||||
|
print("\nTry deleting the model first to force a fresh download:")
|
||||||
|
print(f" curl -X DELETE http://localhost:8000/models/{model_name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"✓ Received {len(events)} SSE events")
|
||||||
|
print(f"\nFirst event: {events[0]}")
|
||||||
|
print(f"Last event: {events[-1]}")
|
||||||
|
|
||||||
|
# Check if we got meaningful progress
|
||||||
|
has_progress = any(e.get('progress', 0) > 0 for e in events)
|
||||||
|
has_complete = any(e.get('status') == 'complete' for e in events)
|
||||||
|
|
||||||
|
if has_progress:
|
||||||
|
print("✓ Progress updates received")
|
||||||
|
else:
|
||||||
|
print("✗ No progress updates (might be already downloaded)")
|
||||||
|
|
||||||
|
if has_complete:
|
||||||
|
print("✓ Download completed successfully")
|
||||||
|
else:
|
||||||
|
print("✗ Download did not complete")
|
||||||
|
|
||||||
|
success = has_progress and has_complete
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("\n✓ TEST PASSED - Progress tracking works!")
|
||||||
|
else:
|
||||||
|
print("\n⊘ TEST INCONCLUSIVE - Try with a fresh download")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -32,11 +32,3 @@ def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
|
|||||||
sf.write(buffer, audio, sample_rate, format="WAV")
|
sf.write(buffer, audio, sample_rate, format="WAV")
|
||||||
buffer.seek(0)
|
buffer.seek(0)
|
||||||
return buffer.read()
|
return buffer.read()
|
||||||
|
|
||||||
|
|
||||||
def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
|
|
||||||
"""Convert audio array to WAV bytes."""
|
|
||||||
buffer = io.BytesIO()
|
|
||||||
sf.write(buffer, audio, sample_rate, format="WAV")
|
|
||||||
buffer.seek(0)
|
|
||||||
return buffer.read()
|
|
||||||
|
|||||||
+122
-3
@@ -70,14 +70,133 @@ def save_audio(
|
|||||||
sample_rate: int = 24000,
|
sample_rate: int = 24000,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Save audio file.
|
Save audio file with atomic write and error handling.
|
||||||
|
|
||||||
|
Writes to a temporary file first, then atomically renames to the
|
||||||
|
target path. This prevents corrupted/partial WAV files if the
|
||||||
|
process is interrupted mid-write.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
audio: Audio array
|
audio: Audio array
|
||||||
path: Output path
|
path: Output path
|
||||||
sample_rate: Sample rate
|
sample_rate: Sample rate
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OSError: If file cannot be written
|
||||||
"""
|
"""
|
||||||
sf.write(path, audio, sample_rate)
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
temp_path = f"{path}.tmp"
|
||||||
|
try:
|
||||||
|
# Ensure parent directory exists
|
||||||
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Write to temporary file first (explicit format since .tmp
|
||||||
|
# extension is not recognised by soundfile)
|
||||||
|
sf.write(temp_path, audio, sample_rate, format='WAV')
|
||||||
|
|
||||||
|
# Atomic rename to final path
|
||||||
|
os.replace(temp_path, path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Clean up temp file on failure
|
||||||
|
try:
|
||||||
|
if Path(temp_path).exists():
|
||||||
|
Path(temp_path).unlink()
|
||||||
|
except Exception:
|
||||||
|
pass # Best effort cleanup
|
||||||
|
|
||||||
|
raise OSError(f"Failed to save audio to {path}: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def trim_tts_output(
|
||||||
|
audio: np.ndarray,
|
||||||
|
sample_rate: int = 24000,
|
||||||
|
frame_ms: int = 20,
|
||||||
|
silence_threshold_db: float = -40.0,
|
||||||
|
min_silence_ms: int = 200,
|
||||||
|
max_internal_silence_ms: int = 1000,
|
||||||
|
fade_ms: int = 30,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Trim trailing silence and post-silence hallucination from TTS output.
|
||||||
|
|
||||||
|
Chatterbox sometimes produces ``[speech][silence][hallucinated noise]``.
|
||||||
|
This detects internal silence gaps longer than *max_internal_silence_ms*
|
||||||
|
and cuts the audio at that boundary, then trims trailing silence and
|
||||||
|
applies a short cosine fade-out.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio: Input audio array (mono float32)
|
||||||
|
sample_rate: Sample rate in Hz
|
||||||
|
frame_ms: Frame size for RMS energy calculation
|
||||||
|
silence_threshold_db: dB threshold below which a frame is silence
|
||||||
|
min_silence_ms: Minimum trailing silence to keep
|
||||||
|
max_internal_silence_ms: Cut after any silence gap longer than this
|
||||||
|
fade_ms: Cosine fade-out duration in ms
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Trimmed audio array
|
||||||
|
"""
|
||||||
|
frame_len = int(sample_rate * frame_ms / 1000)
|
||||||
|
if frame_len == 0 or len(audio) < frame_len:
|
||||||
|
return audio
|
||||||
|
|
||||||
|
n_frames = len(audio) // frame_len
|
||||||
|
threshold_linear = 10 ** (silence_threshold_db / 20)
|
||||||
|
|
||||||
|
# Compute per-frame RMS
|
||||||
|
rms = np.array(
|
||||||
|
[
|
||||||
|
np.sqrt(np.mean(audio[i * frame_len : (i + 1) * frame_len] ** 2))
|
||||||
|
for i in range(n_frames)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
is_speech = rms >= threshold_linear
|
||||||
|
|
||||||
|
# Find first speech frame
|
||||||
|
first_speech = 0
|
||||||
|
for i, s in enumerate(is_speech):
|
||||||
|
if s:
|
||||||
|
first_speech = max(0, i - 1) # keep 1 frame padding
|
||||||
|
break
|
||||||
|
|
||||||
|
# Walk forward from first speech; cut at long internal silence gaps
|
||||||
|
max_silence_frames = int(max_internal_silence_ms / frame_ms)
|
||||||
|
consecutive_silence = 0
|
||||||
|
cut_frame = n_frames
|
||||||
|
|
||||||
|
for i in range(first_speech, n_frames):
|
||||||
|
if is_speech[i]:
|
||||||
|
consecutive_silence = 0
|
||||||
|
else:
|
||||||
|
consecutive_silence += 1
|
||||||
|
if consecutive_silence >= max_silence_frames:
|
||||||
|
cut_frame = i - consecutive_silence + 1
|
||||||
|
break
|
||||||
|
|
||||||
|
# Trim trailing silence from the cut point
|
||||||
|
min_silence_frames = int(min_silence_ms / frame_ms)
|
||||||
|
end_frame = cut_frame
|
||||||
|
while end_frame > first_speech and not is_speech[end_frame - 1]:
|
||||||
|
end_frame -= 1
|
||||||
|
# Keep a short tail
|
||||||
|
end_frame = min(end_frame + min_silence_frames, cut_frame)
|
||||||
|
|
||||||
|
# Convert frames back to samples
|
||||||
|
start_sample = first_speech * frame_len
|
||||||
|
end_sample = min(end_frame * frame_len, len(audio))
|
||||||
|
|
||||||
|
trimmed = audio[start_sample:end_sample].copy()
|
||||||
|
|
||||||
|
# Cosine fade-out
|
||||||
|
fade_samples = int(sample_rate * fade_ms / 1000)
|
||||||
|
if fade_samples > 0 and len(trimmed) > fade_samples:
|
||||||
|
fade = np.cos(np.linspace(0, np.pi / 2, fade_samples)) ** 2
|
||||||
|
trimmed[-fade_samples:] *= fade
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
|
||||||
def validate_reference_audio(
|
def validate_reference_audio(
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""
|
||||||
|
Chunked TTS generation utilities.
|
||||||
|
|
||||||
|
Splits long text into sentence-boundary chunks, generates audio per-chunk
|
||||||
|
via any TTSBackend, and concatenates with crossfade. All logic is
|
||||||
|
engine-agnostic — it wraps the standard ``TTSBackend.generate()`` interface.
|
||||||
|
|
||||||
|
Short text (≤ max_chunk_chars) uses the single-shot fast path with zero
|
||||||
|
overhead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger("voicebox.chunked-tts")
|
||||||
|
|
||||||
|
# Default chunk size in characters. Can be overridden per-request via
|
||||||
|
# the ``max_chunk_chars`` field on GenerationRequest.
|
||||||
|
DEFAULT_MAX_CHUNK_CHARS = 800
|
||||||
|
|
||||||
|
# Common abbreviations that should NOT be treated as sentence endings.
|
||||||
|
# Lowercase for case-insensitive matching.
|
||||||
|
_ABBREVIATIONS = frozenset(
|
||||||
|
{
|
||||||
|
"mr",
|
||||||
|
"mrs",
|
||||||
|
"ms",
|
||||||
|
"dr",
|
||||||
|
"prof",
|
||||||
|
"sr",
|
||||||
|
"jr",
|
||||||
|
"st",
|
||||||
|
"ave",
|
||||||
|
"blvd",
|
||||||
|
"inc",
|
||||||
|
"ltd",
|
||||||
|
"corp",
|
||||||
|
"dept",
|
||||||
|
"est",
|
||||||
|
"approx",
|
||||||
|
"vs",
|
||||||
|
"etc",
|
||||||
|
"e.g",
|
||||||
|
"i.e",
|
||||||
|
"a.m",
|
||||||
|
"p.m",
|
||||||
|
"u.s",
|
||||||
|
"u.s.a",
|
||||||
|
"u.k",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Paralinguistic tags used by Chatterbox Turbo. The splitter must never
|
||||||
|
# cut inside one of these.
|
||||||
|
_PARA_TAG_RE = re.compile(r"\[[^\]]*\]")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Text splitting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def split_text_into_chunks(text: str, max_chars: int = DEFAULT_MAX_CHUNK_CHARS) -> List[str]:
|
||||||
|
"""Split *text* at natural boundaries into chunks of at most *max_chars*.
|
||||||
|
|
||||||
|
Priority: sentence-end (``.!?`` not preceded by an abbreviation and not
|
||||||
|
inside brackets) → clause boundary (``;:,—``) → whitespace → hard cut.
|
||||||
|
|
||||||
|
Paralinguistic tags like ``[laugh]`` are treated as atomic and will not
|
||||||
|
be split across chunks.
|
||||||
|
"""
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
if len(text) <= max_chars:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
chunks: List[str] = []
|
||||||
|
remaining = text
|
||||||
|
|
||||||
|
while remaining:
|
||||||
|
remaining = remaining.lstrip()
|
||||||
|
if not remaining:
|
||||||
|
break
|
||||||
|
if len(remaining) <= max_chars:
|
||||||
|
chunks.append(remaining)
|
||||||
|
break
|
||||||
|
|
||||||
|
segment = remaining[:max_chars]
|
||||||
|
|
||||||
|
# Try to split at the last real sentence ending
|
||||||
|
split_pos = _find_last_sentence_end(segment)
|
||||||
|
if split_pos == -1:
|
||||||
|
split_pos = _find_last_clause_boundary(segment)
|
||||||
|
if split_pos == -1:
|
||||||
|
split_pos = segment.rfind(" ")
|
||||||
|
if split_pos == -1:
|
||||||
|
# Absolute fallback: hard cut but avoid splitting inside a tag
|
||||||
|
split_pos = _safe_hard_cut(segment, max_chars)
|
||||||
|
|
||||||
|
chunk = remaining[: split_pos + 1].strip()
|
||||||
|
if chunk:
|
||||||
|
chunks.append(chunk)
|
||||||
|
remaining = remaining[split_pos + 1 :]
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def _find_last_sentence_end(text: str) -> int:
|
||||||
|
"""Return the index of the last sentence-ending punctuation in *text*.
|
||||||
|
|
||||||
|
Skips periods that follow common abbreviations (``Dr.``, ``Mr.``, etc.)
|
||||||
|
and periods inside bracket tags (``[laugh]``). Also handles CJK
|
||||||
|
sentence-ending punctuation (``。!?``).
|
||||||
|
"""
|
||||||
|
best = -1
|
||||||
|
# ASCII sentence ends
|
||||||
|
for m in re.finditer(r"[.!?](?:\s|$)", text):
|
||||||
|
pos = m.start()
|
||||||
|
char = text[pos]
|
||||||
|
# Skip periods after abbreviations
|
||||||
|
if char == ".":
|
||||||
|
# Walk backwards to find the preceding word
|
||||||
|
word_start = pos - 1
|
||||||
|
while word_start >= 0 and text[word_start].isalpha():
|
||||||
|
word_start -= 1
|
||||||
|
word = text[word_start + 1 : pos].lower()
|
||||||
|
if word in _ABBREVIATIONS:
|
||||||
|
continue
|
||||||
|
# Skip decimal numbers (digit immediately before the period)
|
||||||
|
if word_start >= 0 and text[word_start].isdigit():
|
||||||
|
continue
|
||||||
|
# Skip if we're inside a bracket tag
|
||||||
|
if _inside_bracket_tag(text, pos):
|
||||||
|
continue
|
||||||
|
best = pos
|
||||||
|
# CJK sentence-ending punctuation
|
||||||
|
for m in re.finditer(r"[\u3002\uff01\uff1f]", text):
|
||||||
|
if m.start() > best:
|
||||||
|
best = m.start()
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _find_last_clause_boundary(text: str) -> int:
|
||||||
|
"""Return the index of the last clause-boundary punctuation."""
|
||||||
|
best = -1
|
||||||
|
for m in re.finditer(r"[;:,\u2014](?:\s|$)", text):
|
||||||
|
pos = m.start()
|
||||||
|
# Skip if inside a bracket tag
|
||||||
|
if _inside_bracket_tag(text, pos):
|
||||||
|
continue
|
||||||
|
best = pos
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _inside_bracket_tag(text: str, pos: int) -> bool:
|
||||||
|
"""Return True if *pos* falls inside a ``[...]`` tag."""
|
||||||
|
for m in _PARA_TAG_RE.finditer(text):
|
||||||
|
if m.start() < pos < m.end():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_hard_cut(segment: str, max_chars: int) -> int:
|
||||||
|
"""Find a hard-cut position that doesn't split a ``[tag]``."""
|
||||||
|
cut = max_chars - 1
|
||||||
|
# Check if the cut falls inside a bracket tag; if so, move before it
|
||||||
|
for m in _PARA_TAG_RE.finditer(segment):
|
||||||
|
if m.start() < cut < m.end():
|
||||||
|
return m.start() - 1 if m.start() > 0 else cut
|
||||||
|
return cut
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Audio concatenation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def concatenate_audio_chunks(
|
||||||
|
chunks: List[np.ndarray],
|
||||||
|
sample_rate: int,
|
||||||
|
crossfade_ms: int = 50,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Concatenate audio arrays with a short crossfade to eliminate clicks.
|
||||||
|
|
||||||
|
Each chunk is expected to be a 1-D float32 ndarray at *sample_rate* Hz.
|
||||||
|
"""
|
||||||
|
if not chunks:
|
||||||
|
return np.array([], dtype=np.float32)
|
||||||
|
if len(chunks) == 1:
|
||||||
|
return chunks[0]
|
||||||
|
|
||||||
|
crossfade_samples = int(sample_rate * crossfade_ms / 1000)
|
||||||
|
result = np.array(chunks[0], dtype=np.float32, copy=True)
|
||||||
|
|
||||||
|
for chunk in chunks[1:]:
|
||||||
|
if len(chunk) == 0:
|
||||||
|
continue
|
||||||
|
overlap = min(crossfade_samples, len(result), len(chunk))
|
||||||
|
if overlap > 0:
|
||||||
|
fade_out = np.linspace(1.0, 0.0, overlap, dtype=np.float32)
|
||||||
|
fade_in = np.linspace(0.0, 1.0, overlap, dtype=np.float32)
|
||||||
|
result[-overlap:] = result[-overlap:] * fade_out + chunk[:overlap] * fade_in
|
||||||
|
result = np.concatenate([result, chunk[overlap:]])
|
||||||
|
else:
|
||||||
|
result = np.concatenate([result, chunk])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Engine-agnostic chunked generation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_chunked(
|
||||||
|
backend,
|
||||||
|
text: str,
|
||||||
|
voice_prompt: dict,
|
||||||
|
language: str = "en",
|
||||||
|
seed: int | None = None,
|
||||||
|
instruct: str | None = None,
|
||||||
|
max_chunk_chars: int = DEFAULT_MAX_CHUNK_CHARS,
|
||||||
|
crossfade_ms: int = 50,
|
||||||
|
trim_fn=None,
|
||||||
|
) -> Tuple[np.ndarray, int]:
|
||||||
|
"""Generate audio with automatic chunking for long text.
|
||||||
|
|
||||||
|
For text shorter than *max_chunk_chars* this is a thin wrapper around
|
||||||
|
``backend.generate()`` with zero overhead.
|
||||||
|
|
||||||
|
For longer text the input is split at natural sentence boundaries,
|
||||||
|
each chunk is generated independently, optionally trimmed (useful for
|
||||||
|
Chatterbox engines that hallucinate trailing noise), and the results
|
||||||
|
are concatenated with a crossfade (or hard cut if *crossfade_ms* is 0).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
backend : TTSBackend
|
||||||
|
Any backend implementing the ``generate()`` protocol.
|
||||||
|
text : str
|
||||||
|
Input text (may be arbitrarily long).
|
||||||
|
voice_prompt, language, seed, instruct
|
||||||
|
Forwarded to ``backend.generate()`` verbatim.
|
||||||
|
max_chunk_chars : int
|
||||||
|
Maximum characters per chunk (default 800).
|
||||||
|
crossfade_ms : int
|
||||||
|
Crossfade duration in milliseconds between chunks. 0 for a hard
|
||||||
|
cut with no overlap (default 50).
|
||||||
|
trim_fn : callable | None
|
||||||
|
Optional ``(audio, sample_rate) -> audio`` post-processing
|
||||||
|
function applied to each chunk before concatenation (e.g.
|
||||||
|
``trim_tts_output`` for Chatterbox engines).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
(audio, sample_rate) : Tuple[np.ndarray, int]
|
||||||
|
"""
|
||||||
|
chunks = split_text_into_chunks(text, max_chunk_chars)
|
||||||
|
|
||||||
|
if len(chunks) <= 1:
|
||||||
|
# Short text — single-shot fast path
|
||||||
|
audio, sample_rate = await backend.generate(
|
||||||
|
text, voice_prompt, language, seed, instruct,
|
||||||
|
)
|
||||||
|
if trim_fn is not None:
|
||||||
|
audio = trim_fn(audio, sample_rate)
|
||||||
|
return audio, sample_rate
|
||||||
|
|
||||||
|
# Long text — chunked generation
|
||||||
|
logger.info(
|
||||||
|
"Splitting %d chars into %d chunks (max %d chars each)",
|
||||||
|
len(text), len(chunks), max_chunk_chars,
|
||||||
|
)
|
||||||
|
audio_chunks: List[np.ndarray] = []
|
||||||
|
sample_rate: int | None = None
|
||||||
|
|
||||||
|
for i, chunk_text in enumerate(chunks):
|
||||||
|
logger.info(
|
||||||
|
"Generating chunk %d/%d (%d chars)",
|
||||||
|
i + 1, len(chunks), len(chunk_text),
|
||||||
|
)
|
||||||
|
# Vary the seed per chunk to avoid correlated RNG artefacts,
|
||||||
|
# but keep it deterministic so the same (text, seed) pair
|
||||||
|
# always produces the same output.
|
||||||
|
chunk_seed = (seed + i) if seed is not None else None
|
||||||
|
|
||||||
|
chunk_audio, chunk_sr = await backend.generate(
|
||||||
|
chunk_text, voice_prompt, language, chunk_seed, instruct,
|
||||||
|
)
|
||||||
|
if trim_fn is not None:
|
||||||
|
chunk_audio = trim_fn(chunk_audio, chunk_sr)
|
||||||
|
|
||||||
|
audio_chunks.append(np.asarray(chunk_audio, dtype=np.float32))
|
||||||
|
if sample_rate is None:
|
||||||
|
sample_rate = chunk_sr
|
||||||
|
|
||||||
|
audio = concatenate_audio_chunks(audio_chunks, sample_rate, crossfade_ms=crossfade_ms)
|
||||||
|
return audio, sample_rate
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
Monkey patch for huggingface_hub to force offline mode with cached models.
|
||||||
|
This prevents mlx_audio from making network requests when models are already downloaded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
|
||||||
|
def patch_huggingface_hub_offline():
|
||||||
|
"""
|
||||||
|
Monkey-patch huggingface_hub to force offline mode.
|
||||||
|
This must be called BEFORE importing mlx_audio.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import huggingface_hub
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
from huggingface_hub.file_download import _try_to_load_from_cache
|
||||||
|
|
||||||
|
# Store original function
|
||||||
|
original_try_load = _try_to_load_from_cache
|
||||||
|
|
||||||
|
def _patched_try_to_load_from_cache(
|
||||||
|
repo_id: str,
|
||||||
|
filename: str,
|
||||||
|
cache_dir: Union[str, Path, None] = None,
|
||||||
|
revision: Optional[str] = None,
|
||||||
|
repo_type: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Patched version that forces offline mode.
|
||||||
|
Returns None if not cached (instead of making network request).
|
||||||
|
"""
|
||||||
|
# Always use the original function, but we're already in HF_HUB_OFFLINE mode
|
||||||
|
result = original_try_load(
|
||||||
|
repo_id=repo_id,
|
||||||
|
filename=filename,
|
||||||
|
cache_dir=cache_dir,
|
||||||
|
revision=revision,
|
||||||
|
repo_type=repo_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
# File not in cache - log this for debugging
|
||||||
|
cache_path = Path(hf_constants.HF_HUB_CACHE) / f"models--{repo_id.replace('/', '--')}"
|
||||||
|
print(f"[HF_PATCH] File not cached: {repo_id}/{filename}")
|
||||||
|
print(f"[HF_PATCH] Expected at: {cache_path}")
|
||||||
|
else:
|
||||||
|
print(f"[HF_PATCH] Cache hit: {repo_id}/{filename}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Replace the function
|
||||||
|
import huggingface_hub.file_download as fd
|
||||||
|
fd._try_to_load_from_cache = _patched_try_to_load_from_cache
|
||||||
|
|
||||||
|
print("[HF_PATCH] huggingface_hub patched for offline mode")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
print("[HF_PATCH] huggingface_hub not found, skipping patch")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[HF_PATCH] Error patching huggingface_hub: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_original_qwen_config_cached():
|
||||||
|
"""
|
||||||
|
The MLX community model is based on the original Qwen model.
|
||||||
|
mlx_audio may try to fetch config from the original repo.
|
||||||
|
We need to ensure that config is available in the cache.
|
||||||
|
"""
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
|
||||||
|
# Original Qwen model that mlx_audio might reference
|
||||||
|
original_repo = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||||
|
mlx_repo = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
|
||||||
|
|
||||||
|
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||||
|
|
||||||
|
original_path = cache_dir / f"models--{original_repo.replace('/', '--')}"
|
||||||
|
mlx_path = cache_dir / f"models--{mlx_repo.replace('/', '--')}"
|
||||||
|
|
||||||
|
# If original repo cache doesn't exist but MLX does, create a symlink or copy config
|
||||||
|
if not original_path.exists() and mlx_path.exists():
|
||||||
|
print(f"[HF_PATCH] Original repo not cached, but MLX version is")
|
||||||
|
print(f"[HF_PATCH] Creating symlink from {original_repo} -> {mlx_repo}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a symlink so the cache lookup succeeds
|
||||||
|
original_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
original_path.symlink_to(mlx_path, target_is_directory=True)
|
||||||
|
print(f"[HF_PATCH] Symlink created successfully")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[HF_PATCH] Could not create symlink: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Auto-apply patch when module is imported
|
||||||
|
if os.environ.get("VOICEBOX_OFFLINE_PATCH", "1") != "0":
|
||||||
|
patch_huggingface_hub_offline()
|
||||||
|
ensure_original_qwen_config_cached()
|
||||||
+153
-31
@@ -11,8 +11,9 @@ import sys
|
|||||||
class HFProgressTracker:
|
class HFProgressTracker:
|
||||||
"""Tracks HuggingFace Hub download progress by intercepting tqdm."""
|
"""Tracks HuggingFace Hub download progress by intercepting tqdm."""
|
||||||
|
|
||||||
def __init__(self, progress_callback: Optional[Callable] = None):
|
def __init__(self, progress_callback: Optional[Callable] = None, filter_non_downloads: bool = False):
|
||||||
self.progress_callback = progress_callback
|
self.progress_callback = progress_callback
|
||||||
|
self.filter_non_downloads = filter_non_downloads # Only filter if True
|
||||||
self._original_tqdm_class = None
|
self._original_tqdm_class = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._total_downloaded = 0
|
self._total_downloaded = 0
|
||||||
@@ -21,6 +22,7 @@ class HFProgressTracker:
|
|||||||
self._file_downloaded = {} # Track downloaded bytes per file
|
self._file_downloaded = {} # Track downloaded bytes per file
|
||||||
self._current_filename = ""
|
self._current_filename = ""
|
||||||
self._active_tqdms = {} # Track active tqdm instances
|
self._active_tqdms = {} # Track active tqdm instances
|
||||||
|
self._hf_tqdm_original_update = None # For monkey-patching hf's tqdm
|
||||||
|
|
||||||
def _create_tracked_tqdm_class(self):
|
def _create_tracked_tqdm_class(self):
|
||||||
"""Create a tqdm subclass that tracks progress."""
|
"""Create a tqdm subclass that tracks progress."""
|
||||||
@@ -31,7 +33,6 @@ class HFProgressTracker:
|
|||||||
"""A tqdm subclass that reports progress to our tracker."""
|
"""A tqdm subclass that reports progress to our tracker."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
print(f"[DEBUG TrackedTqdm] __init__ called with desc: {kwargs.get('desc', '')}")
|
|
||||||
# Extract filename from desc before passing to parent
|
# Extract filename from desc before passing to parent
|
||||||
desc = kwargs.get("desc", "")
|
desc = kwargs.get("desc", "")
|
||||||
if not desc and args:
|
if not desc and args:
|
||||||
@@ -80,7 +81,6 @@ class HFProgressTracker:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def update(self, n=1):
|
def update(self, n=1):
|
||||||
print(f"[DEBUG TrackedTqdm] update called with n={n}")
|
|
||||||
result = super().update(n)
|
result = super().update(n)
|
||||||
|
|
||||||
# Report progress
|
# Report progress
|
||||||
@@ -91,6 +91,16 @@ class HFProgressTracker:
|
|||||||
total = getattr(self, "total", 0)
|
total = getattr(self, "total", 0)
|
||||||
|
|
||||||
if total and total > 0:
|
if total and total > 0:
|
||||||
|
# Always filter out non-byte progress bars (e.g., "Fetching 12 files")
|
||||||
|
# These cause crazy percentages because they're counting files, not bytes
|
||||||
|
if self._is_non_byte_progress(filename):
|
||||||
|
return result
|
||||||
|
|
||||||
|
# When model is cached, also filter out generation-related progress
|
||||||
|
if tracker.filter_non_downloads:
|
||||||
|
if not self._is_download_progress(filename):
|
||||||
|
return result
|
||||||
|
|
||||||
# Update per-file tracking
|
# Update per-file tracking
|
||||||
tracker._file_sizes[filename] = total
|
tracker._file_sizes[filename] = total
|
||||||
tracker._file_downloaded[filename] = current
|
tracker._file_downloaded[filename] = current
|
||||||
@@ -99,6 +109,13 @@ class HFProgressTracker:
|
|||||||
tracker._total_size = sum(tracker._file_sizes.values())
|
tracker._total_size = sum(tracker._file_sizes.values())
|
||||||
tracker._total_downloaded = sum(tracker._file_downloaded.values())
|
tracker._total_downloaded = sum(tracker._file_downloaded.values())
|
||||||
|
|
||||||
|
# Only report progress once we have a meaningful total (at least 1MB)
|
||||||
|
# This avoids the "100% at 0MB" issue when small config
|
||||||
|
# files are counted before the real model files
|
||||||
|
MIN_TOTAL_BYTES = 1_000_000 # 1MB
|
||||||
|
if tracker._total_size < MIN_TOTAL_BYTES:
|
||||||
|
return result
|
||||||
|
|
||||||
# Call progress callback
|
# Call progress callback
|
||||||
if tracker.progress_callback:
|
if tracker.progress_callback:
|
||||||
tracker.progress_callback(
|
tracker.progress_callback(
|
||||||
@@ -109,6 +126,50 @@ class HFProgressTracker:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _is_non_byte_progress(self, filename: str) -> bool:
|
||||||
|
"""Check if this progress bar should be SKIPPED (returns True to skip).
|
||||||
|
|
||||||
|
We want to track byte-based progress bars. This method identifies
|
||||||
|
progress bars that count files/items instead of bytes, which would
|
||||||
|
cause crazy percentages if mixed with our byte counting.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True = SKIP this bar (it's not byte-based)
|
||||||
|
False = TRACK this bar (it counts bytes)
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
return False
|
||||||
|
|
||||||
|
filename_lower = filename.lower()
|
||||||
|
|
||||||
|
# Skip "Fetching X files" - it counts files (total=12), not bytes
|
||||||
|
# Don't skip "Downloading (incomplete total...)" - that IS byte-based
|
||||||
|
skip_patterns = [
|
||||||
|
'fetching', # "Fetching 12 files" has total=12 files, not bytes
|
||||||
|
]
|
||||||
|
return any(pattern in filename_lower for pattern in skip_patterns)
|
||||||
|
|
||||||
|
def _is_download_progress(self, filename: str) -> bool:
|
||||||
|
"""Check if this is a real file download progress bar vs internal processing."""
|
||||||
|
if not filename or filename == "unknown":
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Real downloads have file extensions
|
||||||
|
download_extensions = [
|
||||||
|
'.safetensors', '.bin', '.pt', '.pth', # Model weights
|
||||||
|
'.json', '.txt', '.py', # Config files
|
||||||
|
'.msgpack', '.h5', # Other formats
|
||||||
|
]
|
||||||
|
|
||||||
|
filename_lower = filename.lower()
|
||||||
|
has_extension = any(filename_lower.endswith(ext) for ext in download_extensions)
|
||||||
|
|
||||||
|
# Skip generation-related progress indicators
|
||||||
|
skip_patterns = ['segment', 'processing', 'generating', 'loading']
|
||||||
|
has_skip_pattern = any(pattern in filename_lower for pattern in skip_patterns)
|
||||||
|
|
||||||
|
return has_extension and not has_skip_pattern
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
with tracker._lock:
|
with tracker._lock:
|
||||||
if id(self) in tracker._active_tqdms:
|
if id(self) in tracker._active_tqdms:
|
||||||
@@ -120,13 +181,11 @@ class HFProgressTracker:
|
|||||||
@contextmanager
|
@contextmanager
|
||||||
def patch_download(self):
|
def patch_download(self):
|
||||||
"""Context manager to patch tqdm for progress tracking."""
|
"""Context manager to patch tqdm for progress tracking."""
|
||||||
print("[DEBUG HFProgressTracker] patch_download called")
|
|
||||||
try:
|
try:
|
||||||
import tqdm as tqdm_module
|
import tqdm as tqdm_module
|
||||||
|
|
||||||
# Store original tqdm class
|
# Store original tqdm class
|
||||||
self._original_tqdm_class = tqdm_module.tqdm
|
self._original_tqdm_class = tqdm_module.tqdm
|
||||||
print(f"[DEBUG HFProgressTracker] Original tqdm class: {self._original_tqdm_class}")
|
|
||||||
|
|
||||||
# Reset totals
|
# Reset totals
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -139,39 +198,89 @@ class HFProgressTracker:
|
|||||||
|
|
||||||
# Create our tracked tqdm class
|
# Create our tracked tqdm class
|
||||||
tracked_tqdm = self._create_tracked_tqdm_class()
|
tracked_tqdm = self._create_tracked_tqdm_class()
|
||||||
print(f"[DEBUG HFProgressTracker] Created TrackedTqdm class: {tracked_tqdm}")
|
|
||||||
|
|
||||||
# Patch tqdm.tqdm
|
# Patch tqdm.tqdm
|
||||||
tqdm_module.tqdm = tracked_tqdm
|
tqdm_module.tqdm = tracked_tqdm
|
||||||
print(f"[DEBUG HFProgressTracker] Patched tqdm.tqdm")
|
|
||||||
|
|
||||||
# Also patch tqdm.auto.tqdm if it exists (used by huggingface_hub)
|
# Also patch tqdm.auto.tqdm if it exists (used by huggingface_hub)
|
||||||
self._original_tqdm_auto = None
|
self._original_tqdm_auto = None
|
||||||
if hasattr(tqdm_module, "auto") and hasattr(tqdm_module.auto, "tqdm"):
|
if hasattr(tqdm_module, "auto") and hasattr(tqdm_module.auto, "tqdm"):
|
||||||
self._original_tqdm_auto = tqdm_module.auto.tqdm
|
self._original_tqdm_auto = tqdm_module.auto.tqdm
|
||||||
tqdm_module.auto.tqdm = tracked_tqdm
|
tqdm_module.auto.tqdm = tracked_tqdm
|
||||||
print(f"[DEBUG HFProgressTracker] Patched tqdm.auto.tqdm")
|
|
||||||
|
|
||||||
# Patch in sys.modules to catch already-imported references
|
# Patch in sys.modules to catch already-imported references
|
||||||
|
# huggingface_hub uses: from tqdm.auto import tqdm as base_tqdm
|
||||||
|
# So we need to patch both 'tqdm' and 'base_tqdm' attributes
|
||||||
self._patched_modules = {}
|
self._patched_modules = {}
|
||||||
|
tqdm_attr_names = ['tqdm', 'base_tqdm', 'old_tqdm'] # Various names used
|
||||||
|
|
||||||
patched_count = 0
|
patched_count = 0
|
||||||
for module_name in list(sys.modules.keys()):
|
for module_name in list(sys.modules.keys()):
|
||||||
if "huggingface" in module_name or module_name.startswith("tqdm"):
|
if "huggingface" in module_name or module_name.startswith("tqdm"):
|
||||||
try:
|
try:
|
||||||
module = sys.modules[module_name]
|
module = sys.modules[module_name]
|
||||||
if hasattr(module, "tqdm"):
|
for attr_name in tqdm_attr_names:
|
||||||
attr = getattr(module, "tqdm")
|
if hasattr(module, attr_name):
|
||||||
# Only patch if it's the original tqdm class (not already patched)
|
attr = getattr(module, attr_name)
|
||||||
if attr is self._original_tqdm_class or (
|
# Only patch if it's a tqdm class (not already patched)
|
||||||
hasattr(attr, "__name__") and attr.__name__ == "tqdm"
|
is_tqdm_class = (
|
||||||
):
|
attr is self._original_tqdm_class or
|
||||||
self._patched_modules[module_name] = attr
|
(self._original_tqdm_auto and attr is self._original_tqdm_auto) or
|
||||||
setattr(module, "tqdm", tracked_tqdm)
|
(hasattr(attr, "__name__") and attr.__name__ == "tqdm" and
|
||||||
patched_count += 1
|
hasattr(attr, "update")) # tqdm classes have update method
|
||||||
print(f"[DEBUG HFProgressTracker] Patched {module_name}.tqdm")
|
)
|
||||||
|
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):
|
except (AttributeError, TypeError):
|
||||||
pass
|
pass
|
||||||
print(f"[DEBUG HFProgressTracker] Patched {patched_count} modules in sys.modules")
|
|
||||||
|
# ALSO monkey-patch the update method on huggingface_hub's tqdm class
|
||||||
|
# This is needed because the class was already defined at import time
|
||||||
|
self._hf_tqdm_original_update = None
|
||||||
|
try:
|
||||||
|
from huggingface_hub.utils import tqdm as hf_tqdm_module
|
||||||
|
if hasattr(hf_tqdm_module, 'tqdm'):
|
||||||
|
hf_tqdm_class = hf_tqdm_module.tqdm
|
||||||
|
self._hf_tqdm_original_update = hf_tqdm_class.update
|
||||||
|
|
||||||
|
# Create a wrapper that calls our tracking
|
||||||
|
tracker = self # Reference to HFProgressTracker instance
|
||||||
|
def patched_update(tqdm_self, n=1):
|
||||||
|
result = tracker._hf_tqdm_original_update(tqdm_self, n)
|
||||||
|
|
||||||
|
# Track this progress
|
||||||
|
with tracker._lock:
|
||||||
|
desc = getattr(tqdm_self, 'desc', '') or ''
|
||||||
|
current = getattr(tqdm_self, 'n', 0)
|
||||||
|
total = getattr(tqdm_self, 'total', 0) or 0
|
||||||
|
|
||||||
|
# Skip non-byte progress bars
|
||||||
|
if 'fetching' in desc.lower():
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Skip until we have a meaningful total (at least 1MB)
|
||||||
|
# This avoids the "100% at 0MB" issue when small config
|
||||||
|
# files are counted before the real model files
|
||||||
|
MIN_TOTAL_BYTES = 1_000_000 # 1MB
|
||||||
|
if total >= MIN_TOTAL_BYTES:
|
||||||
|
tracker._total_downloaded = current
|
||||||
|
tracker._total_size = total
|
||||||
|
|
||||||
|
if tracker.progress_callback:
|
||||||
|
tracker.progress_callback(current, total, desc)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
hf_tqdm_class.update = patched_update
|
||||||
|
patched_count += 1
|
||||||
|
print(f"[HFProgressTracker] Monkey-patched huggingface_hub.utils.tqdm.tqdm.update")
|
||||||
|
except (ImportError, AttributeError) as e:
|
||||||
|
print(f"[HFProgressTracker] Could not monkey-patch hf_tqdm: {e}")
|
||||||
|
|
||||||
|
print(f"[HFProgressTracker] Patched {patched_count} tqdm references")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -189,15 +298,24 @@ class HFProgressTracker:
|
|||||||
tqdm_module.auto.tqdm = self._original_tqdm_auto
|
tqdm_module.auto.tqdm = self._original_tqdm_auto
|
||||||
|
|
||||||
# Restore patched modules
|
# Restore patched modules
|
||||||
for module_name, original in self._patched_modules.items():
|
for key, (module, attr_name, original) in self._patched_modules.items():
|
||||||
try:
|
try:
|
||||||
module = sys.modules.get(module_name)
|
|
||||||
if module and original:
|
if module and original:
|
||||||
setattr(module, "tqdm", original)
|
setattr(module, attr_name, original)
|
||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
pass
|
pass
|
||||||
self._patched_modules = {}
|
self._patched_modules = {}
|
||||||
|
|
||||||
|
# Restore hf_tqdm's original update method
|
||||||
|
if self._hf_tqdm_original_update:
|
||||||
|
try:
|
||||||
|
from huggingface_hub.utils import tqdm as hf_tqdm_module
|
||||||
|
if hasattr(hf_tqdm_module, 'tqdm'):
|
||||||
|
hf_tqdm_module.tqdm.update = self._hf_tqdm_original_update
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
pass
|
||||||
|
self._hf_tqdm_original_update = None
|
||||||
|
|
||||||
except (ImportError, AttributeError):
|
except (ImportError, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -205,13 +323,17 @@ class HFProgressTracker:
|
|||||||
def create_hf_progress_callback(model_name: str, progress_manager):
|
def create_hf_progress_callback(model_name: str, progress_manager):
|
||||||
"""Create a progress callback for HuggingFace downloads."""
|
"""Create a progress callback for HuggingFace downloads."""
|
||||||
def callback(downloaded: int, total: int, filename: str = ""):
|
def callback(downloaded: int, total: int, filename: str = ""):
|
||||||
"""Progress callback."""
|
"""Progress callback.
|
||||||
if total > 0:
|
|
||||||
progress_manager.update_progress(
|
Note: We send updates even when total=0 (unknown) to provide feedback
|
||||||
model_name=model_name,
|
during the "incomplete total" phase of huggingface_hub downloads.
|
||||||
current=downloaded,
|
The frontend handles total=0 gracefully.
|
||||||
total=total,
|
"""
|
||||||
filename=filename or "",
|
progress_manager.update_progress(
|
||||||
status="downloading",
|
model_name=model_name,
|
||||||
)
|
current=downloaded,
|
||||||
|
total=total,
|
||||||
|
filename=filename or "",
|
||||||
|
status="downloading",
|
||||||
|
)
|
||||||
return callback
|
return callback
|
||||||
|
|||||||
+42
-11
@@ -16,11 +16,17 @@ class ProgressManager:
|
|||||||
Thread-safe: can be called from background threads (e.g., via asyncio.to_thread).
|
Thread-safe: can be called from background threads (e.g., via asyncio.to_thread).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Throttle settings to prevent overwhelming SSE clients
|
||||||
|
THROTTLE_INTERVAL_SECONDS = 0.5 # Minimum time between updates
|
||||||
|
THROTTLE_PROGRESS_DELTA = 1.0 # Minimum progress change (%) to force update
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._progress: Dict[str, Dict] = {}
|
self._progress: Dict[str, Dict] = {}
|
||||||
self._listeners: Dict[str, list] = {}
|
self._listeners: Dict[str, list] = {}
|
||||||
self._lock = threading.Lock() # Thread-safe lock for progress dict
|
self._lock = threading.Lock() # Thread-safe lock for progress dict
|
||||||
self._main_loop: Optional[asyncio.AbstractEventLoop] = None
|
self._main_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
self._last_notify_time: Dict[str, float] = {} # Last notification time per model
|
||||||
|
self._last_notify_progress: Dict[str, float] = {} # Last notified progress per model
|
||||||
|
|
||||||
def _set_main_loop(self, loop: asyncio.AbstractEventLoop):
|
def _set_main_loop(self, loop: asyncio.AbstractEventLoop):
|
||||||
"""Set the main event loop for thread-safe operations."""
|
"""Set the main event loop for thread-safe operations."""
|
||||||
@@ -67,6 +73,10 @@ class ProgressManager:
|
|||||||
Update progress for a model download.
|
Update progress for a model download.
|
||||||
|
|
||||||
Thread-safe: can be called from background threads.
|
Thread-safe: can be called from background threads.
|
||||||
|
|
||||||
|
Progress updates are throttled to prevent overwhelming SSE clients.
|
||||||
|
Updates are sent at most every THROTTLE_INTERVAL_SECONDS, or when
|
||||||
|
progress changes by at least THROTTLE_PROGRESS_DELTA percent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_name: Name of the model (e.g., "qwen-tts-1.7B", "whisper-base")
|
model_name: Name of the model (e.g., "qwen-tts-1.7B", "whisper-base")
|
||||||
@@ -76,9 +86,17 @@ class ProgressManager:
|
|||||||
status: Status string (downloading, extracting, complete, error)
|
status: Status string (downloading, extracting, complete, error)
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
progress_pct = (current / total * 100) if total > 0 else 0
|
# Calculate progress percentage, clamped to 0-100 range
|
||||||
|
# This prevents crazy percentages from edge cases like:
|
||||||
|
# - current > total temporarily during aggregation
|
||||||
|
# - mixing file-count progress with byte-count progress
|
||||||
|
if total > 0:
|
||||||
|
progress_pct = min(100.0, max(0.0, (current / total * 100)))
|
||||||
|
else:
|
||||||
|
progress_pct = 0
|
||||||
|
|
||||||
progress_data = {
|
progress_data = {
|
||||||
"model_name": model_name,
|
"model_name": model_name,
|
||||||
@@ -90,25 +108,38 @@ class ProgressManager:
|
|||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
print(f"[DEBUG] update_progress called: {model_name}, {progress_pct:.1f}%")
|
# Thread-safe update of progress dict (always update internal state)
|
||||||
|
|
||||||
# Thread-safe update of progress dict
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._progress[model_name] = progress_data
|
self._progress[model_name] = progress_data
|
||||||
|
|
||||||
|
# Check if we should notify listeners (throttling)
|
||||||
|
current_time = time.time()
|
||||||
|
last_time = self._last_notify_time.get(model_name, 0)
|
||||||
|
last_progress = self._last_notify_progress.get(model_name, -100)
|
||||||
|
|
||||||
|
time_delta = current_time - last_time
|
||||||
|
progress_delta = abs(progress_pct - last_progress)
|
||||||
|
|
||||||
|
# Always notify for complete/error status, or if throttle conditions are met
|
||||||
|
should_notify = (
|
||||||
|
status in ("complete", "error") or
|
||||||
|
time_delta >= self.THROTTLE_INTERVAL_SECONDS or
|
||||||
|
progress_delta >= self.THROTTLE_PROGRESS_DELTA
|
||||||
|
)
|
||||||
|
|
||||||
|
if not should_notify:
|
||||||
|
return # Skip this update (throttled)
|
||||||
|
|
||||||
|
# Update throttle tracking
|
||||||
|
self._last_notify_time[model_name] = current_time
|
||||||
|
self._last_notify_progress[model_name] = progress_pct
|
||||||
|
|
||||||
# Notify all listeners (thread-safe)
|
# Notify all listeners (thread-safe)
|
||||||
listener_count = len(self._listeners.get(model_name, []))
|
listener_count = len(self._listeners.get(model_name, []))
|
||||||
print(f"[DEBUG] Listener count for {model_name}: {listener_count}")
|
|
||||||
print(f"[DEBUG] All listeners: {list(self._listeners.keys())}")
|
|
||||||
print(f"[DEBUG] Main loop set: {self._main_loop is not None}")
|
|
||||||
if self._main_loop:
|
|
||||||
print(f"[DEBUG] Main loop running: {self._main_loop.is_running()}")
|
|
||||||
|
|
||||||
if listener_count > 0:
|
if listener_count > 0:
|
||||||
logger.debug(f"Notifying {listener_count} listeners for {model_name}: {progress_pct:.1f}% ({filename})")
|
logger.debug(f"Notifying {listener_count} listeners for {model_name}: {progress_pct:.1f}% ({filename})")
|
||||||
print(f"[DEBUG] About to notify listeners...")
|
|
||||||
self._notify_listeners_threadsafe(model_name, progress_data)
|
self._notify_listeners_threadsafe(model_name, progress_data)
|
||||||
print(f"[DEBUG] Notified listeners")
|
|
||||||
else:
|
else:
|
||||||
logger.debug(f"No listeners for {model_name}, progress update stored: {progress_pct:.1f}%")
|
logger.debug(f"No listeners for {model_name}, progress update stored: {progress_pct:.1f}%")
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ class TaskManager:
|
|||||||
"""Get all active generations."""
|
"""Get all active generations."""
|
||||||
return list(self._active_generations.values())
|
return list(self._active_generations.values())
|
||||||
|
|
||||||
|
def cancel_download(self, model_name: str) -> bool:
|
||||||
|
"""Cancel/dismiss a download task (removes it from active list)."""
|
||||||
|
return self._active_downloads.pop(model_name, None) is not None
|
||||||
|
|
||||||
|
def clear_all(self) -> None:
|
||||||
|
"""Clear all download and generation tasks."""
|
||||||
|
self._active_downloads.clear()
|
||||||
|
self._active_generations.clear()
|
||||||
|
|
||||||
def is_download_active(self, model_name: str) -> bool:
|
def is_download_active(self, model_name: str) -> bool:
|
||||||
"""Check if a download is active."""
|
"""Check if a download is active."""
|
||||||
return model_name in self._active_downloads
|
return model_name in self._active_downloads
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ from PyInstaller.utils.hooks import copy_metadata
|
|||||||
datas = []
|
datas = []
|
||||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
||||||
datas += collect_data_files('qwen_tts')
|
datas += collect_data_files('qwen_tts')
|
||||||
datas += collect_data_files('mlx')
|
# Use collect_all (not collect_data_files) so native .dylib and .metallib
|
||||||
datas += collect_data_files('mlx_audio')
|
# files are bundled as binaries, not data. Without this, MLX raises OSError
|
||||||
|
# when loading Metal shaders inside the PyInstaller bundle.
|
||||||
|
from PyInstaller.utils.hooks import collect_all as _collect_all
|
||||||
|
_mlx_datas, _mlx_bins, _mlx_hidden = _collect_all('mlx')
|
||||||
|
_mlxa_datas, _mlxa_bins, _mlxa_hidden = _collect_all('mlx_audio')
|
||||||
|
datas += _mlx_datas + _mlxa_datas
|
||||||
datas += copy_metadata('qwen-tts')
|
datas += copy_metadata('qwen-tts')
|
||||||
hiddenimports += collect_submodules('qwen_tts')
|
hiddenimports += collect_submodules('qwen_tts')
|
||||||
hiddenimports += collect_submodules('jaraco')
|
hiddenimports += collect_submodules('jaraco')
|
||||||
@@ -18,7 +23,7 @@ hiddenimports += collect_submodules('mlx_audio')
|
|||||||
a = Analysis(
|
a = Analysis(
|
||||||
['server.py'],
|
['server.py'],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=_mlx_bins + _mlxa_bins,
|
||||||
datas=datas,
|
datas=datas,
|
||||||
hiddenimports=hiddenimports,
|
hiddenimports=hiddenimports,
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "voicebox",
|
"name": "voicebox",
|
||||||
|
"dependencies": {
|
||||||
|
"loaders.css": "^0.1.2",
|
||||||
|
"react-loaders": "^3.0.1",
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.3.12",
|
"@biomejs/biome": "2.3.12",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
@@ -13,7 +17,7 @@
|
|||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.9",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -68,7 +72,7 @@
|
|||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"name": "@voicebox/landing",
|
"name": "@voicebox/landing",
|
||||||
"version": "0.1.9",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
@@ -93,10 +97,14 @@
|
|||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"name": "@voicebox/tauri",
|
"name": "@voicebox/tauri",
|
||||||
"version": "0.1.9",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-process": "^2.0.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
@@ -112,7 +120,7 @@
|
|||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"name": "@voicebox/web",
|
"name": "@voicebox/web",
|
||||||
"version": "0.1.9",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
@@ -121,6 +129,7 @@
|
|||||||
"zustand": "^4.5.0",
|
"zustand": "^4.5.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
@@ -673,6 +682,8 @@
|
|||||||
|
|
||||||
"class-variance-authority": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
"class-variance-authority": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
|
"classnames": ["[email protected]", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||||
|
|
||||||
"client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
"client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||||
|
|
||||||
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
@@ -869,6 +880,8 @@
|
|||||||
|
|
||||||
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
|
"loaders.css": ["[email protected]", "", {}, "sha512-Rhowlq24ey1VOeor+3wYOt9+MjaxBOJm1u4KlQgNC3+0xJ0LS4wq4iG57D/BPzvuD/7HHDGQOWJ+81oR2EI9bQ=="],
|
||||||
|
|
||||||
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||||
@@ -955,6 +968,8 @@
|
|||||||
|
|
||||||
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|
||||||
|
"prop-types": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||||
|
|
||||||
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
@@ -965,6 +980,10 @@
|
|||||||
|
|
||||||
"react-hook-form": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="],
|
"react-hook-form": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="],
|
||||||
|
|
||||||
|
"react-is": ["[email protected]", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
|
"react-loaders": ["[email protected]", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
|
||||||
|
|
||||||
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||||
|
|
||||||
"react-remove-scroll": ["[email protected]", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
"react-remove-scroll": ["[email protected]", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
services:
|
||||||
|
voicebox:
|
||||||
|
build: .
|
||||||
|
container_name: voicebox
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ports:
|
||||||
|
# Bind to localhost only for security
|
||||||
|
- "127.0.0.1:17493:17493"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Bind-mount for generated audio (customize the host path as needed)
|
||||||
|
# Host side: ./output/
|
||||||
|
# Container side: /app/data/generations/
|
||||||
|
- ./output:/app/data/generations
|
||||||
|
|
||||||
|
# Named volume for profiles, DB, cache (persists across container restarts)
|
||||||
|
- voicebox-data:/app/data
|
||||||
|
|
||||||
|
# HuggingFace model cache (so models aren't re-downloaded on rebuild)
|
||||||
|
- huggingface-cache:/home/voicebox/.cache/huggingface
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- voicebox-net
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '4'
|
||||||
|
memory: 8G
|
||||||
|
|
||||||
|
networks:
|
||||||
|
voicebox-net:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
voicebox-data:
|
||||||
|
huggingface-cache:
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Accessibility: screen reader and keyboard improvements
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Improvements to support screen reader and keyboard users across the main app surfaces: audio player, generation UI, voice selection, history, voices tab, model management, server tab, and stories.
|
||||||
|
|
||||||
|
**Tested with NVDA and Narrator on Windows.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
### Audio player (after generating audio)
|
||||||
|
|
||||||
|
- **Play/Pause, Loop, Mute, Close** – `aria-label` added so each control is announced (e.g. "Play", "Pause", "Loop", "Mute", "Close player").
|
||||||
|
- **Playback position slider** – `aria-label="Playback position"` and `aria-valuetext` with current/total time (e.g. "0:30 of 2:15").
|
||||||
|
- **Volume** – Wrapped in a labelled group; volume slider has an associated screen-reader-only label and `aria-valuetext` for the level (e.g. "Volume level, 75%").
|
||||||
|
|
||||||
|
### Generation UI (text box and voice choice)
|
||||||
|
|
||||||
|
- **Generate speech** (submit) and **Fine-tune instructions** (sliders) – Icon buttons now have `aria-label` (and state for fine-tune, e.g. "Fine-tune instructions, on").
|
||||||
|
|
||||||
|
### Voice selection (cards on Generate screen)
|
||||||
|
|
||||||
|
- Each **voice card** is focusable (`tabIndex={0}`), has `role="button"`, and an `aria-label` (e.g. "Prashant, en. Select as voice for generation.") with `aria-pressed` when selected.
|
||||||
|
- **Enter/Space** on the card selects that voice; tab order is card → Export/Edit/Delete.
|
||||||
|
|
||||||
|
### History list (generated samples)
|
||||||
|
|
||||||
|
- Each **sample row** is focusable with `role="button"` and an `aria-label` (e.g. "Sample from [profile], [duration], [date]. Press Enter to play."); **Enter/Space** plays or restarts.
|
||||||
|
- **Transcript textarea** has `aria-label` (e.g. "Transcript for sample from [profile], [duration]") so when you focus on the text area, the sample is announced in context.
|
||||||
|
|
||||||
|
### Voices tab (table)
|
||||||
|
|
||||||
|
- Each **voice row** is focusable with `role="button"` and an `aria-label` (e.g. "[Name], [language], [N] generations, [N] samples. Press Enter to edit."); **Enter/Space** opens edit (except when focus is in a control).
|
||||||
|
- **Actions** dropdown trigger has `aria-label="Actions for [profile name]"`.
|
||||||
|
|
||||||
|
### Model management
|
||||||
|
|
||||||
|
- Each **model row** is a focusable region (`tabIndex={0}`, `role="group"`) with an `aria-label` (e.g. "[Model name], [status], [size]. Use Tab to reach Download or Delete.").
|
||||||
|
- **Download** and **Delete** (and Downloading) buttons have `aria-label` (e.g. "Download [name]", "Delete [name]").
|
||||||
|
|
||||||
|
### Server tab (panels)
|
||||||
|
|
||||||
|
- **Server Connection**, **Server Status**, and **App Updates** cards are landmarks: `role="region"`, `aria-label`, and `tabIndex={0}` so each panel is focusable and announced (e.g. "Server Connection", "Server Status", "App Updates").
|
||||||
|
|
||||||
|
### Stories list
|
||||||
|
|
||||||
|
- Each **story row** is a focusable control (`role="button"`, `tabIndex={0}`) with `aria-label` (e.g. "Story [name], [N] items, [date]. Press Enter to select."); **Enter/Space** selects the story. Actions button has `aria-label="Actions for [story name]"`.
|
||||||
|
|
||||||
|
### Other controls
|
||||||
|
|
||||||
|
- **Story list** – Actions (⋮) button: `aria-label="Actions for [story name]"`.
|
||||||
|
- **Story track editor** – Play/Pause, Stop, Split, Duplicate, Delete, Zoom in/out: `aria-label` on all icon buttons.
|
||||||
|
- **Voice profile samples** (SampleList, AudioSampleUpload, AudioSampleRecording, AudioSampleSystem) – Play/Pause and Stop: `aria-label` (e.g. "Play sample", "Pause", "Stop playback").
|
||||||
|
- **SampleList** mini sample player – Seek slider has `aria-label="Sample playback position"` and `aria-valuetext` for time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Screen readers:** Tested with **NVDA** and **Narrator** on Windows.
|
||||||
|
- **Keyboard:** Tab order and Enter/Space activation verified for focusable rows and buttons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech note
|
||||||
|
|
||||||
|
- React + TypeScript; Radix UI primitives; labels added via `aria-label`, `aria-labelledby`, `aria-valuetext`, and `role`/`tabIndex` where needed.
|
||||||
|
- No new dependencies.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user