mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-28 06:35:18 -07:00
Compare commits
240
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e5f4606a6c | ||
|
|
146ef5aaeb | ||
|
|
971604d14f | ||
|
|
0b17073345 | ||
|
|
17106b1e40 | ||
|
|
953e6ec7d8 | ||
|
|
d3c65fc6c2 | ||
|
|
7fcca09f24 | ||
|
|
b6e772c6ac | ||
|
|
a6b070201b | ||
|
|
30352e2419 | ||
|
|
bfa38b36b7 | ||
|
|
1b66a528d1 | ||
|
|
bef4092e6e | ||
|
|
9654f7b642 | ||
|
|
eba1244add | ||
|
|
94487f32a5 | ||
|
|
081f45e680 | ||
|
|
86768288ce | ||
|
|
0fd063442a | ||
|
|
a0c2493e98 | ||
|
|
b39f48cc81 | ||
|
|
4ff775bc98 | ||
|
|
6351aa75e9 | ||
|
|
43873a883b | ||
|
|
60012b81c0 | ||
|
|
89f3127c37 | ||
|
|
ef3c3a7f8c | ||
|
|
7b5e73cfa8 | ||
|
|
462f104494 | ||
|
|
fadb57164e | ||
|
|
e870d65136 | ||
|
|
3df40278cc | ||
|
|
deeef5a474 | ||
|
|
236e464525 | ||
|
|
cf3cf3f002 | ||
|
|
9d98e1e768 | ||
|
|
3be8980f48 | ||
|
|
76bc070f5b | ||
|
|
01838f4773 | ||
|
|
39e4f9d08c | ||
|
|
341d71470c | ||
|
|
bb6cea24ba | ||
|
|
fa7ac88abc | ||
|
|
c68ddc45b1 | ||
|
|
f89dc66d0c | ||
|
|
cba7d7bc23 | ||
|
|
3c89b068f3 | ||
|
|
229841e05e | ||
|
|
123e8215e4 | ||
|
|
2a3afec2ca | ||
|
|
99ddd5a0b4 | ||
|
|
8d730621bc | ||
|
|
e23118f610 | ||
|
|
d4bfdc0d68 | ||
|
|
116c108906 | ||
|
|
2d23c8e06a | ||
|
|
3973a59ba3 | ||
|
|
d9aa75253a | ||
|
|
b22bf36565 | ||
|
|
33f4ed9b44 | ||
|
|
cc37e04221 | ||
|
|
2b4fbe5173 | ||
|
|
423d69b7cc | ||
|
|
ea943876dc | ||
|
|
b55d8cc567 | ||
|
|
036d90dc8e | ||
|
|
c513451277 | ||
|
|
27ae6dfbab | ||
|
|
51b9e2fd3d | ||
|
|
be25ddbe0e | ||
|
|
9cd4921291 | ||
|
|
2349bd24ba | ||
|
|
cd82ed0664 | ||
|
|
c4884a0443 | ||
|
|
232d231788 | ||
|
|
1cf90c81dd | ||
|
|
3204e193fa | ||
|
|
9d5d6cb56a | ||
|
|
153eaba5f3 | ||
|
|
3370e3b419 | ||
|
|
615bd188a0 | ||
|
|
7208f51eee | ||
|
|
07a91a2381 | ||
|
|
70cc36857d | ||
|
|
7f66d02591 | ||
|
|
9f7a5a492e | ||
|
|
cb44377b09 | ||
|
|
a42a946586 | ||
|
|
a3cbe7f2b6 | ||
|
|
d8d9eeaa6a | ||
|
|
f7cb219f6d | ||
|
|
7f18c09628 | ||
|
|
d9c7121c5b | ||
|
|
008b58f91c | ||
|
|
c7404411d5 | ||
|
|
ac08c4fcf4 | ||
|
|
3ce7498495 | ||
|
|
ce2f09d29e | ||
|
|
f1be633dca | ||
|
|
5f58c4dc3d | ||
|
|
892f363e3a | ||
|
|
535cf362de | ||
|
|
f1116e05a6 | ||
|
|
f9aca9d418 | ||
|
|
d913a9ae2a | ||
|
|
36031a0df5 | ||
|
|
e59f86aa63 | ||
|
|
b59c0f44e5 | ||
|
|
f8c5e54962 | ||
|
|
8b67faf96d | ||
|
|
30ea627ae8 |
+4
-4
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.1.1
|
||||
current_version = 0.1.13
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
@@ -34,6 +34,6 @@ replace = "version": "{new_version}"
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
|
||||
[bumpversion:file:backend/main.py]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
[bumpversion:file:backend/__init__.py]
|
||||
search = __version__ = "{current_version}"
|
||||
replace = __version__ = "{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:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -14,18 +14,22 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target aarch64-apple-darwin'
|
||||
python-version: '3.12'
|
||||
- platform: 'macos-15-intel'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
python-version: '3.12'
|
||||
# - platform: 'ubuntu-22.04'
|
||||
# args: ''
|
||||
# python-version: '3.12'
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
python-version: '3.12'
|
||||
- platform: "macos-latest"
|
||||
args: "--target aarch64-apple-darwin"
|
||||
python-version: "3.12"
|
||||
backend: "mlx"
|
||||
- platform: "macos-15-intel"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
python-version: "3.12"
|
||||
backend: "pytorch"
|
||||
- platform: "ubuntu-22.04"
|
||||
args: ""
|
||||
python-version: "3.12"
|
||||
backend: "pytorch"
|
||||
- platform: "windows-latest"
|
||||
args: ""
|
||||
python-version: "3.12"
|
||||
backend: "pytorch"
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
@@ -49,7 +53,7 @@ jobs:
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
cache: "pip"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
@@ -57,6 +61,17 @@ jobs:
|
||||
pip install pyinstaller
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Install MLX dependencies (Apple Silicon only)
|
||||
if: matrix.backend == 'mlx'
|
||||
run: |
|
||||
pip install -r backend/requirements-mlx.txt
|
||||
|
||||
# - name: Install PyTorch with CUDA (Windows only)
|
||||
# if: matrix.platform == 'windows-latest'
|
||||
# run: |
|
||||
# pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall --no-deps
|
||||
# pip install torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||
|
||||
- name: Build Python server (Linux/macOS)
|
||||
if: matrix.platform != 'windows-latest'
|
||||
run: |
|
||||
@@ -91,7 +106,7 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './tauri/src-tauri -> target'
|
||||
workspaces: "./tauri/src-tauri -> target"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
@@ -127,13 +142,14 @@ jobs:
|
||||
with:
|
||||
projectPath: tauri
|
||||
tagName: v__VERSION__
|
||||
releaseName: 'voicebox v__VERSION__'
|
||||
releaseName: "voicebox v__VERSION__"
|
||||
releaseBody: |
|
||||
## What's Changed
|
||||
See the assets below to download and install this version.
|
||||
|
||||
### Installation
|
||||
- **macOS**: Download the `.dmg` file
|
||||
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference
|
||||
- **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch
|
||||
- **Windows**: Download the `.msi` installer
|
||||
- **Linux**: Download the `.AppImage` or `.deb` package
|
||||
|
||||
|
||||
@@ -30,8 +30,6 @@ target/
|
||||
*.swo
|
||||
*~
|
||||
|
||||
tauri/src-tauri/gen/Assets.car
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Profile Name Validation** - Added proper validation to prevent duplicate profile names ([#134](https://github.com/jamiepine/voicebox/issues/134))
|
||||
- Users now receive clear error messages when attempting to create or update profiles with duplicate names
|
||||
- Improved error handling in create and update profile API endpoints
|
||||
- Added comprehensive test suite for duplicate name validation
|
||||
|
||||
## [0.1.0] - 2026-01-25
|
||||
|
||||
### Added
|
||||
@@ -53,6 +61,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Audio export failing when Tauri save dialog returns object instead of string path
|
||||
- OpenAPI client generator script now documents the local backend port and avoids an unused loop variable warning
|
||||
|
||||
### Added
|
||||
- **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
|
||||
- Includes Python version detection and compatibility warnings
|
||||
- Self-documenting help system with `make help`
|
||||
- Colored output for better readability
|
||||
- Supports parallel development server execution
|
||||
|
||||
### Changed
|
||||
- **README** - Added Makefile reference and updated Quick Start with Makefile-based setup instructions alongside manual setup
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased - Planned]
|
||||
|
||||
### Planned
|
||||
- Real-time streaming synthesis
|
||||
- Conversation mode with multiple speakers
|
||||
|
||||
+86
-18
@@ -27,11 +27,35 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
||||
```bash
|
||||
rustc --version # Check if installed
|
||||
```
|
||||
- **[Tauri Prerequisites](https://v2.tauri.app/start/prerequisites)** - Tauri-specific system dependencies (varies by OS).
|
||||
|
||||
- **Git** - Version control
|
||||
|
||||
### Development Setup
|
||||
|
||||
**Using `just` (recommended):**
|
||||
|
||||
Install [just](https://github.com/casey/just) (`brew install just` or `cargo install just`), then:
|
||||
|
||||
```bash
|
||||
just setup # creates venv, installs Python + JS deps
|
||||
just dev # starts backend + desktop app in one terminal
|
||||
```
|
||||
|
||||
Other useful commands:
|
||||
|
||||
```bash
|
||||
just dev-web # backend + web app (no Tauri/Rust build)
|
||||
just dev-backend # backend only
|
||||
just kill # stop all dev processes
|
||||
just clean-all # nuke everything and start fresh
|
||||
just --list # see all available commands
|
||||
```
|
||||
|
||||
**Using the Makefile:** Run `make setup` then `make dev`. See `make help` for all commands.
|
||||
|
||||
**Manual setup (required for Windows):**
|
||||
|
||||
1. **Fork and clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
||||
@@ -62,37 +86,43 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
||||
# Install Python dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Install MLX dependencies (Apple Silicon only - for faster inference)
|
||||
# On Apple Silicon, this enables native Metal acceleration
|
||||
if [[ $(uname -m) == "arm64" ]]; then
|
||||
pip install -r requirements-mlx.txt
|
||||
fi
|
||||
|
||||
# Install Qwen3-TTS (required for voice synthesis)
|
||||
pip install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
```
|
||||
|
||||
4. **Initialize database**
|
||||
```bash
|
||||
cd backend
|
||||
python -c "from database import init_db; init_db()"
|
||||
```
|
||||
This creates the SQLite database at `data/voicebox.db`.
|
||||
4. **Start development servers**
|
||||
|
||||
5. **Start development servers**
|
||||
|
||||
**Terminal 1: Backend server**
|
||||
Development requires two terminals: one for the Python backend, one for the Tauri app.
|
||||
|
||||
**Terminal 1: Backend server** (start this first)
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate # Activate venv if not already active
|
||||
bun run dev:server
|
||||
# Or manually: uvicorn main:app --reload --port 8000
|
||||
# Or manually: uvicorn main:app --reload --port 17493
|
||||
```
|
||||
Backend will be available at `http://localhost:8000`
|
||||
|
||||
Backend will be available at `http://localhost:17493`
|
||||
|
||||
**Terminal 2: Desktop app**
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
This will:
|
||||
- Create a placeholder sidecar binary (for Tauri compilation)
|
||||
- Start Vite dev server on port 5173
|
||||
- Launch Tauri window pointing to localhost:5173
|
||||
- Connect to the Python server you started in Terminal 1
|
||||
- Enable hot reload
|
||||
|
||||
> **Note:** In dev mode, the app connects to your manually-started Python server.
|
||||
> The bundled server binary is only used in production builds.
|
||||
|
||||
**Optional: Web app**
|
||||
```bash
|
||||
bun run dev:web
|
||||
@@ -109,18 +139,36 @@ First-time usage will be slower due to model downloads, but subsequent runs will
|
||||
|
||||
### Building
|
||||
|
||||
**Build Python server binary:**
|
||||
**Build everything (recommended):**
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
This automatically:
|
||||
1. Builds the Python server binary (`./scripts/build-server.sh`)
|
||||
2. Builds the Tauri desktop app (`cd tauri && bun run tauri build`)
|
||||
|
||||
Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src-tauri/target/release/bundle/`.
|
||||
|
||||
**Note:** The build process detects your platform and includes the appropriate backend (MLX for Apple Silicon, PyTorch for others).
|
||||
|
||||
**Build server binary only:**
|
||||
```bash
|
||||
bun run build:server
|
||||
# or
|
||||
./scripts/build-server.sh
|
||||
```
|
||||
Creates platform-specific binary in `tauri/src-tauri/binaries/`
|
||||
|
||||
**Build Tauri desktop app:**
|
||||
**Building with local Qwen3-TTS development version:**
|
||||
|
||||
If you're actively developing or modifying the Qwen3-TTS library, set the `QWEN_TTS_PATH` environment variable to point to your local clone:
|
||||
|
||||
```bash
|
||||
cd tauri
|
||||
bun run tauri build
|
||||
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS
|
||||
bun run build:server
|
||||
```
|
||||
Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`)
|
||||
|
||||
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package. Useful when testing changes to the TTS library before they're published to PyPI or when using an editable install (`pip install -e`).
|
||||
|
||||
**Build web app:**
|
||||
```bash
|
||||
@@ -137,6 +185,26 @@ After starting the backend server:
|
||||
```
|
||||
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`
|
||||
|
||||
### Convert Assets to Web Formats
|
||||
|
||||
To optimize images and videos for the web, run:
|
||||
```bash
|
||||
bun run convert:assets
|
||||
```
|
||||
|
||||
This script:
|
||||
- Converts PNG → WebP (better compression, same quality)
|
||||
- Converts MOV → WebM (VP9 codec, smaller file size)
|
||||
- Processes files in `landing/public/` and `docs/public/`
|
||||
- **Deletes original files** after successful conversion
|
||||
|
||||
**Requirements:** Install `webp` and `ffmpeg`:
|
||||
```bash
|
||||
brew install webp ffmpeg
|
||||
```
|
||||
|
||||
> **Note:** Run this before committing new images or videos to keep the repository size small.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. Create a Branch
|
||||
@@ -359,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
|
||||
- **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?
|
||||
|
||||
|
||||
+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"]
|
||||
@@ -0,0 +1,250 @@
|
||||
# Voicebox Makefile
|
||||
# Unix-only (macOS/Linux). Windows users should use WSL.
|
||||
|
||||
SHELL := /bin/bash
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# Directories
|
||||
BACKEND_DIR := backend
|
||||
TAURI_DIR := tauri
|
||||
WEB_DIR := web
|
||||
APP_DIR := app
|
||||
|
||||
# Python (prefer 3.12, fallback to 3.13, then python3)
|
||||
PYTHON := $(shell command -v python3.12 2>/dev/null || command -v python3.13 2>/dev/null || echo python3)
|
||||
VENV := $(CURDIR)/$(BACKEND_DIR)/venv
|
||||
VENV_BIN := $(VENV)/bin
|
||||
PIP := $(VENV_BIN)/pip
|
||||
PYTHON_VENV := $(VENV_BIN)/python
|
||||
|
||||
# Colors for output
|
||||
BLUE := \033[0;34m
|
||||
GREEN := \033[0;32m
|
||||
YELLOW := \033[0;33m
|
||||
NC := \033[0m # No Color
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help message
|
||||
@echo -e "$(BLUE)Voicebox$(NC) - Development Commands"
|
||||
@echo ""
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
|
||||
awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-20s$(NC) %s\n", $$1, $$2}'
|
||||
|
||||
# =============================================================================
|
||||
# SETUP
|
||||
# =============================================================================
|
||||
|
||||
.PHONY: setup setup-js setup-python setup-rust
|
||||
|
||||
setup: setup-js setup-python ## Full project setup (all dependencies)
|
||||
@echo -e "$(GREEN)✓ Setup complete!$(NC)"
|
||||
@echo -e " Run $(YELLOW)make dev$(NC) to start development servers"
|
||||
|
||||
setup-js: ## Install JavaScript dependencies (bun)
|
||||
@echo -e "$(BLUE)Installing JavaScript dependencies...$(NC)"
|
||||
bun install
|
||||
|
||||
setup-python: $(VENV)/bin/activate ## Set up Python virtual environment and dependencies
|
||||
@echo -e "$(BLUE)Installing Python dependencies...$(NC)"
|
||||
$(PIP) install --upgrade pip
|
||||
$(PIP) install -r $(BACKEND_DIR)/requirements.txt
|
||||
$(PIP) install --no-deps chatterbox-tts
|
||||
@if [ "$$(uname -m)" = "arm64" ] && [ "$$(uname)" = "Darwin" ]; then \
|
||||
echo -e "$(BLUE)Detected Apple Silicon - installing MLX dependencies...$(NC)"; \
|
||||
$(PIP) install -r $(BACKEND_DIR)/requirements-mlx.txt; \
|
||||
echo -e "$(GREEN)✓ MLX backend enabled (native Metal acceleration)$(NC)"; \
|
||||
fi
|
||||
$(PIP) install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
@echo -e "$(GREEN)✓ Python environment ready$(NC)"
|
||||
|
||||
$(VENV)/bin/activate:
|
||||
@echo -e "$(BLUE)Creating Python virtual environment...$(NC)"
|
||||
@PY_MINOR=$$($(PYTHON) -c "import sys; print(sys.version_info[1])"); \
|
||||
if [ "$$PY_MINOR" -gt 13 ]; then \
|
||||
echo -e "$(YELLOW)Warning: Python 3.$$PY_MINOR detected. ML packages may not be compatible.$(NC)"; \
|
||||
echo -e "$(YELLOW)Recommended: Use Python 3.12 or 3.13 (brew install [email protected])$(NC)"; \
|
||||
fi
|
||||
$(PYTHON) -m venv $(VENV)
|
||||
|
||||
setup-rust: ## Install Rust toolchain (if not present)
|
||||
@command -v rustc >/dev/null 2>&1 || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# =============================================================================
|
||||
# DEVELOPMENT
|
||||
# =============================================================================
|
||||
|
||||
.PHONY: dev dev-backend dev-frontend dev-web kill-dev
|
||||
|
||||
dev: ## Start backend + desktop app (parallel)
|
||||
@echo -e "$(BLUE)Starting development servers...$(NC)"
|
||||
@echo -e "$(YELLOW)Note: If Tauri fails, run 'make build-server' first or use separate terminals$(NC)"
|
||||
@trap 'kill 0' EXIT; \
|
||||
$(MAKE) dev-backend & \
|
||||
sleep 2 && if [ "$$(uname)" = "Linux" ] && lspci 2>/dev/null | grep -qi nvidia; then \
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 $(MAKE) dev-frontend; \
|
||||
else \
|
||||
$(MAKE) dev-frontend; \
|
||||
fi & \
|
||||
wait
|
||||
|
||||
dev-backend: ## Start FastAPI backend server
|
||||
@echo -e "$(BLUE)Starting backend server on http://localhost:17493$(NC)"
|
||||
$(VENV_BIN)/uvicorn backend.main:app --reload --port 17493
|
||||
|
||||
dev-frontend: ## Start Tauri desktop app
|
||||
@echo -e "$(BLUE)Starting Tauri desktop app...$(NC)"
|
||||
bun run dev
|
||||
|
||||
dev-web: ## Start backend + web app (parallel)
|
||||
@echo -e "$(BLUE)Starting web development servers...$(NC)"
|
||||
@trap 'kill 0' EXIT; \
|
||||
$(MAKE) dev-backend & \
|
||||
sleep 2 && cd $(WEB_DIR) && bun run dev & \
|
||||
wait
|
||||
|
||||
kill-dev: ## Kill all development processes
|
||||
@echo -e "$(YELLOW)Killing development processes...$(NC)"
|
||||
-pkill -f "uvicorn main:app" 2>/dev/null || true
|
||||
-pkill -f "vite" 2>/dev/null || true
|
||||
@echo -e "$(GREEN)✓ Processes killed$(NC)"
|
||||
|
||||
# =============================================================================
|
||||
# BUILD
|
||||
# =============================================================================
|
||||
|
||||
.PHONY: build build-server build-tauri build-web
|
||||
|
||||
build: build-server build-tauri ## Build everything (server binary + desktop app)
|
||||
@echo -e "$(GREEN)✓ Build complete!$(NC)"
|
||||
|
||||
build-server: ## Build Python server binary
|
||||
@echo -e "$(BLUE)Building server binary...$(NC)"
|
||||
PATH="$(VENV_BIN):$$PATH" ./scripts/build-server.sh
|
||||
|
||||
build-tauri: ## Build Tauri desktop app
|
||||
@echo -e "$(BLUE)Building Tauri desktop app...$(NC)"
|
||||
cd $(TAURI_DIR) && bun run tauri build
|
||||
|
||||
build-web: ## Build web app
|
||||
@echo -e "$(BLUE)Building web app...$(NC)"
|
||||
cd $(WEB_DIR) && bun run build
|
||||
@echo -e "$(GREEN)✓ Web build output in $(WEB_DIR)/dist/$(NC)"
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE & API
|
||||
# =============================================================================
|
||||
|
||||
.PHONY: db-init db-reset generate-api
|
||||
|
||||
db-init: $(VENV)/bin/activate ## Initialize SQLite database
|
||||
@echo -e "$(BLUE)Initializing database...$(NC)"
|
||||
cd $(BACKEND_DIR) && $(PYTHON_VENV) -c "from database import init_db; init_db()"
|
||||
@echo -e "$(GREEN)✓ Database created at $(BACKEND_DIR)/data/voicebox.db$(NC)"
|
||||
|
||||
db-reset: ## Reset database (delete and reinitialize)
|
||||
@echo -e "$(YELLOW)Resetting database...$(NC)"
|
||||
rm -f $(BACKEND_DIR)/data/voicebox.db
|
||||
$(MAKE) db-init
|
||||
|
||||
generate-api: ## Generate TypeScript API client from OpenAPI schema
|
||||
@echo -e "$(BLUE)Generating API client...$(NC)"
|
||||
@echo -e "$(YELLOW)Note: Backend must be running (make dev-backend)$(NC)"
|
||||
./scripts/generate-api.sh
|
||||
@echo -e "$(GREEN)✓ API client generated in $(APP_DIR)/src/lib/api/$(NC)"
|
||||
|
||||
# =============================================================================
|
||||
# CODE QUALITY
|
||||
# =============================================================================
|
||||
|
||||
.PHONY: lint format typecheck check
|
||||
|
||||
lint: ## Run linter (Biome)
|
||||
@echo -e "$(BLUE)Linting...$(NC)"
|
||||
bun run lint
|
||||
|
||||
format: ## Format code (Biome)
|
||||
@echo -e "$(BLUE)Formatting...$(NC)"
|
||||
bun run format
|
||||
|
||||
typecheck: ## Run TypeScript type checking
|
||||
@echo -e "$(BLUE)Type checking...$(NC)"
|
||||
bun run tsc --noEmit
|
||||
|
||||
check: ## Run all checks (Biome lint + format + type check)
|
||||
@echo -e "$(BLUE)Running all checks...$(NC)"
|
||||
bun run check
|
||||
@echo -e "$(GREEN)✓ All checks passed$(NC)"
|
||||
|
||||
# =============================================================================
|
||||
# TESTING
|
||||
# =============================================================================
|
||||
|
||||
.PHONY: test test-backend test-frontend
|
||||
|
||||
test: test-backend test-frontend ## Run all tests
|
||||
@echo -e "$(GREEN)✓ All tests passed$(NC)"
|
||||
|
||||
test-backend: ## Run Python backend tests (requires pytest)
|
||||
@echo -e "$(BLUE)Running backend tests...$(NC)"
|
||||
@if [ -f "$(VENV_BIN)/pytest" ]; then \
|
||||
cd $(BACKEND_DIR) && $(VENV_BIN)/pytest -v; \
|
||||
else \
|
||||
echo -e "$(YELLOW)pytest not installed. Run: $(PIP) install pytest$(NC)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
test-frontend: ## Run frontend tests (requires test script in package.json)
|
||||
@echo -e "$(BLUE)Running frontend tests...$(NC)"
|
||||
@if bun run test --help >/dev/null 2>&1; then \
|
||||
bun run test; \
|
||||
else \
|
||||
echo -e "$(YELLOW)No test script configured$(NC)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# LOGS & DEBUGGING
|
||||
# =============================================================================
|
||||
|
||||
.PHONY: logs docs
|
||||
|
||||
logs: ## Tail backend logs
|
||||
@echo -e "$(BLUE)Tailing logs (Ctrl+C to stop)...$(NC)"
|
||||
tail -f $(BACKEND_DIR)/logs/*.log 2>/dev/null || echo "No log files found"
|
||||
|
||||
docs: ## Open API documentation (backend must be running)
|
||||
@echo -e "$(BLUE)Opening API docs...$(NC)"
|
||||
open http://localhost:17493/docs 2>/dev/null || xdg-open http://localhost:17493/docs
|
||||
|
||||
# =============================================================================
|
||||
# CLEAN
|
||||
# =============================================================================
|
||||
|
||||
.PHONY: clean clean-python clean-build clean-all
|
||||
|
||||
clean: ## Clean build artifacts
|
||||
@echo -e "$(BLUE)Cleaning build artifacts...$(NC)"
|
||||
rm -rf $(TAURI_DIR)/src-tauri/target/release
|
||||
rm -rf $(WEB_DIR)/dist
|
||||
rm -rf $(APP_DIR)/dist
|
||||
@echo -e "$(GREEN)✓ Build artifacts cleaned$(NC)"
|
||||
|
||||
clean-python: ## Clean Python cache and virtual environment
|
||||
@echo -e "$(BLUE)Cleaning Python files...$(NC)"
|
||||
rm -rf $(VENV)
|
||||
find $(BACKEND_DIR) -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
find $(BACKEND_DIR) -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
@echo -e "$(GREEN)✓ Python environment cleaned$(NC)"
|
||||
|
||||
clean-build: ## Clean Rust/Tauri build cache
|
||||
@echo -e "$(BLUE)Cleaning Rust build cache...$(NC)"
|
||||
cd $(TAURI_DIR)/src-tauri && cargo clean
|
||||
@echo -e "$(GREEN)✓ Rust cache cleaned$(NC)"
|
||||
|
||||
clean-all: clean clean-python clean-build ## Nuclear clean (everything)
|
||||
@echo -e "$(BLUE)Cleaning node_modules...$(NC)"
|
||||
rm -rf node_modules
|
||||
rm -rf $(APP_DIR)/node_modules
|
||||
rm -rf $(TAURI_DIR)/node_modules
|
||||
rm -rf $(WEB_DIR)/node_modules
|
||||
@echo -e "$(GREEN)✓ Full clean complete$(NC)"
|
||||
@@ -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*
|
||||
@@ -10,6 +10,21 @@
|
||||
All running locally on your machine.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/jamiepine/voicebox/releases">
|
||||
<img src="https://img.shields.io/github/downloads/jamiepine/voicebox/total?style=flat&color=blue" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://github.com/jamiepine/voicebox/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/jamiepine/voicebox?style=flat" alt="Release" />
|
||||
</a>
|
||||
<a href="https://github.com/jamiepine/voicebox/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/jamiepine/voicebox?style=flat" alt="Stars" />
|
||||
</a>
|
||||
<a href="https://github.com/jamiepine/voicebox/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/jamiepine/voicebox?style=flat" alt="License" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://voicebox.sh">voicebox.sh</a> •
|
||||
<a href="#download">Download</a> •
|
||||
@@ -22,7 +37,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://voicebox.sh">
|
||||
<img src=".github/assets/screenshot.webp" alt="Voicebox App Screenshot" width="800" />
|
||||
<img src="landing/public/assets/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -32,17 +47,30 @@
|
||||
|
||||
<br/>
|
||||
|
||||
## Why Voicebox?
|
||||
<p align="center">
|
||||
<img src="landing/public/assets/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
|
||||
</p>
|
||||
|
||||
Voice AI is exploding, but most tools are either cloud-locked, expensive, or a nightmare to set up. Voicebox is different:
|
||||
<p align="center">
|
||||
<img src="landing/public/assets/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
|
||||
</p>
|
||||
|
||||
- **100% Local** — Your voice data never leaves your machine
|
||||
- **Lightweight** — No bloated Electron, native Tauri performance
|
||||
- **Fast** — Near-instant on CUDA, optimized for Apple Silicon
|
||||
- **Flexible** — Use the app, integrate the API, or both
|
||||
- **Open Source** — No subscriptions, no limits, no lock-in
|
||||
<br/>
|
||||
|
||||
Built with **Tauri** (Rust), **TypeScript**, **React**, and **Python**. Native performance meets modern DX.
|
||||
## What is Voicebox?
|
||||
|
||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as a **local, free and open-source alternative to ElevenLabs** — download models, clone voices, and generate speech entirely on your machine.
|
||||
|
||||
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives you:
|
||||
|
||||
- **Complete privacy** — models and voice data stay on your machine
|
||||
- **Professional tools** — multi-track timeline editor, audio trimming, conversation mixing
|
||||
- **Model flexibility** — currently powered by Qwen3-TTS, with support for XTTS, Bark, and other models coming soon
|
||||
- **API-first** — use the desktop app or integrate voice synthesis into your own projects
|
||||
- **Native performance** — built with Tauri (Rust), not Electron
|
||||
- **Super fast on Mac** — MLX backend with native Metal acceleration for 4-5x faster inference on Apple Silicon
|
||||
|
||||
Download a voice model, clone any voice from a few seconds of audio, and compose multi-voice projects with studio-grade editing tools. No Python install required, no cloud dependency, no limits.
|
||||
|
||||
---
|
||||
|
||||
@@ -52,10 +80,10 @@ Voicebox is available now for macOS and Windows.
|
||||
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| macOS (Apple Silicon) | [voicebox_aarch64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_aarch64.app.tar.gz) |
|
||||
| macOS (Intel) | [voicebox_x64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_x64.app.tar.gz) |
|
||||
| Windows (MSI) | [voicebox_0.1.0_x64_en-US.msi](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_0.1.0_x64_en-US.msi) |
|
||||
| Windows (Setup) | [voicebox_0.1.0_x64-setup.exe](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_0.1.0_x64-setup.exe) |
|
||||
| macOS (Apple Silicon) | [Voicebox_aarch64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/latest/download/Voicebox_aarch64.app.tar.gz) |
|
||||
| macOS (Intel) | [Voicebox_x64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/latest/download/Voicebox_x64.app.tar.gz) |
|
||||
| Windows (MSI) | [Latest Windows MSI](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||
| Windows (Setup) | [Latest Windows Setup](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||
|
||||
> **Linux builds coming soon** — Currently blocked by GitHub runner disk space limitations.
|
||||
|
||||
@@ -70,11 +98,13 @@ Powered by Alibaba's **Qwen3-TTS** — a breakthrough model that achieves near-p
|
||||
- **Instant cloning** — Upload a sample, get a voice profile
|
||||
- **High fidelity** — Natural prosody, emotion, and cadence
|
||||
- **Multi-language** — English, Chinese, and more coming
|
||||
- **Lightning fast on Mac** — MLX backend leverages Apple Silicon's Neural Engine for super-fast generation
|
||||
|
||||
### Voice Profile Management
|
||||
|
||||
- **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
|
||||
- **Organize** with descriptions and language tags
|
||||
|
||||
### Speech Generation
|
||||
@@ -83,9 +113,19 @@ Powered by Alibaba's **Qwen3-TTS** — a breakthrough model that achieves near-p
|
||||
- **Batch generation** for long-form content
|
||||
- **Smart caching** — regenerate instantly with voice prompt caching
|
||||
|
||||
### Stories Editor
|
||||
|
||||
Create multi-voice narratives, podcasts, and conversations with a timeline-based editor.
|
||||
|
||||
- **Multi-track composition** — arrange multiple voice tracks in a single project
|
||||
- **Inline audio editing** — trim and split clips directly in the timeline
|
||||
- **Auto-playback** — preview stories with synchronized playhead
|
||||
- **Voice mixing** — build conversations with multiple participants
|
||||
|
||||
### Recording & Transcription
|
||||
|
||||
- **In-app recording** with waveform visualization
|
||||
- **System audio capture** — record desktop audio on macOS and Windows
|
||||
- **Automatic transcription** powered by Whisper
|
||||
- **Export recordings** in multiple formats
|
||||
|
||||
@@ -107,19 +147,22 @@ Powered by Alibaba's **Qwen3-TTS** — a breakthrough model that achieves near-p
|
||||
|
||||
Voicebox exposes a full REST API, so you can integrate voice synthesis into your own apps.
|
||||
|
||||
For the current local app and development workflow, the backend is typically available at `http://localhost:17493`.
|
||||
If you launch the backend manually with a different host or port, use that address instead.
|
||||
|
||||
```bash
|
||||
# Generate speech
|
||||
curl -X POST http://localhost:8000/api/generate \
|
||||
curl -X POST http://localhost:17493/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text": "Hello world", "profile_id": "abc123"}'
|
||||
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
|
||||
|
||||
# List voice profiles
|
||||
curl http://localhost:8000/api/profiles
|
||||
curl http://localhost:17493/profiles
|
||||
|
||||
# Create a profile from audio
|
||||
curl -X POST http://localhost:8000/api/profiles \
|
||||
-F "[email protected]" \
|
||||
-F "name=My Voice"
|
||||
# Create a profile
|
||||
curl -X POST http://localhost:17493/profiles \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "My Voice", "language": "en"}'
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
@@ -130,7 +173,7 @@ curl -X POST http://localhost:8000/api/profiles \
|
||||
- Voice assistants
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -142,8 +185,9 @@ Full API documentation available at `http://localhost:8000/docs` when running.
|
||||
| Frontend | React, TypeScript, Tailwind CSS |
|
||||
| State | Zustand, React Query |
|
||||
| Backend | FastAPI (Python) |
|
||||
| Voice Model | Qwen3-TTS |
|
||||
| Transcription | Whisper |
|
||||
| Voice Model | Qwen3-TTS (PyTorch or MLX) |
|
||||
| Transcription | Whisper (PyTorch or MLX) |
|
||||
| Inference Engine | MLX (Apple Silicon) / PyTorch (Windows/Linux/Intel) |
|
||||
| Database | SQLite |
|
||||
| Audio | WaveSurfer.js, librosa |
|
||||
|
||||
@@ -187,21 +231,22 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup and contribution guide
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/voicebox-sh/voicebox.git
|
||||
git clone https://github.com/jamiepine/voicebox.git
|
||||
cd voicebox
|
||||
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Install Python dependencies
|
||||
cd backend && pip install -r requirements.txt && cd ..
|
||||
|
||||
# Start development
|
||||
bun run dev
|
||||
just setup # creates Python venv, installs all deps
|
||||
just dev # starts backend + desktop app
|
||||
```
|
||||
|
||||
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org). CUDA-capable GPU recommended (CPU inference supported but slower).
|
||||
Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands.
|
||||
|
||||
Also available via Makefile: `make setup && make dev` (run `make help` for all commands).
|
||||
|
||||
**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/).
|
||||
|
||||
**Performance:**
|
||||
- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration for 4-5x faster inference
|
||||
- **Windows/Linux/Intel Mac**: Uses PyTorch backend (CUDA GPU recommended, CPU supported but slower)
|
||||
|
||||
### Project Structure
|
||||
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -13,6 +13,9 @@
|
||||
"check": "biome check --write src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
@@ -30,6 +33,7 @@
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-query-devtools": "^5.0.0",
|
||||
"@tanstack/react-router": "^1.157.16",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||
@@ -44,6 +48,7 @@
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-sound-visualizer": "^1.4.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"wavesurfer.js": "^7.0.0",
|
||||
"zod": "^3.23.8",
|
||||
|
||||
+50
-134
@@ -1,31 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
||||
import { GenerationForm } from '@/components/Generation/GenerationForm';
|
||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
||||
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
||||
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||
import ShinyText from '@/components/ShinyText';
|
||||
import { Sidebar } from '@/components/Sidebar';
|
||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
||||
import {
|
||||
isMacOS,
|
||||
isTauri,
|
||||
setKeepServerRunning,
|
||||
setupWindowCloseHandler,
|
||||
startServer,
|
||||
} from '@/lib/tauri';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { router } from '@/router';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
// Track if server is starting to prevent duplicate starts
|
||||
let serverStarting = false;
|
||||
|
||||
const LOADING_MESSAGES = [
|
||||
'Warming up tensors...',
|
||||
'Calibrating synthesizer engine...',
|
||||
@@ -50,32 +34,45 @@ const LOADING_MESSAGES = [
|
||||
];
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState('main');
|
||||
const platform = usePlatform();
|
||||
const [serverReady, setServerReady] = useState(false);
|
||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||
const serverStartingRef = useRef(false);
|
||||
|
||||
// Monitor active downloads/generations and show toasts for them
|
||||
const activeDownloads = useRestoreActiveTasks();
|
||||
// Automatically check for app updates on startup and show toast notifications
|
||||
useAutoUpdater({ checkOnMount: true, showToast: true });
|
||||
|
||||
// Sync stored setting to Rust on startup
|
||||
useEffect(() => {
|
||||
if (isTauri()) {
|
||||
if (platform.metadata.isTauri) {
|
||||
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
||||
setKeepServerRunning(keepRunning).catch((error) => {
|
||||
platform.lifecycle.setKeepServerRunning(keepRunning).catch((error) => {
|
||||
console.error('Failed to sync initial setting to Rust:', error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||
|
||||
// Setup lifecycle callbacks
|
||||
useEffect(() => {
|
||||
platform.lifecycle.onServerReady = () => {
|
||||
setServerReady(true);
|
||||
};
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.lifecycle]);
|
||||
|
||||
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||
useEffect(() => {
|
||||
if (!isTauri()) {
|
||||
if (!platform.metadata.isTauri) {
|
||||
setServerReady(true); // Web assumes server is running
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup window close handler to check setting and stop server if needed
|
||||
// This works in both dev and prod, but will only stop server if it was started by the app
|
||||
setupWindowCloseHandler().catch((error) => {
|
||||
platform.lifecycle.setupWindowCloseHandler().catch((error) => {
|
||||
console.error('Failed to setup window close handler:', error);
|
||||
});
|
||||
|
||||
@@ -91,14 +88,17 @@ function App() {
|
||||
}
|
||||
|
||||
// Auto-start server in production
|
||||
if (serverStarting) {
|
||||
if (serverStartingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
serverStarting = true;
|
||||
console.log('Production mode: Starting bundled server...');
|
||||
serverStartingRef.current = true;
|
||||
const isRemote = useServerStore.getState().mode === 'remote';
|
||||
const customModelsDir = useServerStore.getState().customModelsDir;
|
||||
console.log(`Production mode: Starting bundled server... (remote: ${isRemote})`);
|
||||
|
||||
startServer(false)
|
||||
platform.lifecycle
|
||||
.startServer(isRemote, customModelsDir)
|
||||
.then((serverUrl) => {
|
||||
console.log('Server is ready at:', serverUrl);
|
||||
// Update the server URL in the store with the dynamically assigned port
|
||||
@@ -110,7 +110,7 @@ function App() {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to auto-start server:', error);
|
||||
serverStarting = false;
|
||||
serverStartingRef.current = false;
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = false;
|
||||
});
|
||||
@@ -119,13 +119,15 @@ function App() {
|
||||
// Note: Window close is handled separately in Tauri Rust code
|
||||
return () => {
|
||||
// Window close event handles server shutdown based on setting
|
||||
serverStarting = false;
|
||||
serverStartingRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||
|
||||
// Cycle through loading messages every 3 seconds
|
||||
useEffect(() => {
|
||||
if (!isTauri() || serverReady) {
|
||||
if (!platform.metadata.isTauri || serverReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -134,12 +136,17 @@ function App() {
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [serverReady]);
|
||||
}, [serverReady, platform.metadata.isTauri]);
|
||||
|
||||
// Show loading screen while server is starting in Tauri
|
||||
if (isTauri() && !serverReady) {
|
||||
if (platform.metadata.isTauri && !serverReady) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center pt-12">
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-screen bg-background flex items-center justify-center',
|
||||
TOP_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
<TitleBarDragRegion />
|
||||
<div className="text-center space-y-6">
|
||||
<div className="flex justify-center relative">
|
||||
@@ -166,98 +173,7 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-background flex flex-col overflow-hidden pt-12">
|
||||
<TitleBarDragRegion />
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} isMacOS={isMacOS()} />
|
||||
|
||||
<main className="flex-1 ml-20 overflow-hidden flex flex-col">
|
||||
<div className="container mx-auto px-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
|
||||
{activeTab === 'settings' ? (
|
||||
<div className="space-y-4 overflow-y-auto flex flex-col">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ConnectionForm />
|
||||
<ServerStatus />
|
||||
</div>
|
||||
{isTauri() && <UpdateStatus />}
|
||||
<ModelManagement />
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
Created by{' '}
|
||||
<a
|
||||
href="https://github.com/jamiepine"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Jamie Pine
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 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">
|
||||
{/* Left Column */}
|
||||
<div className="flex flex-col gap-6 min-h-0 overflow-y-auto pb-32">
|
||||
{/* Profiles - Top Left */}
|
||||
<div className="shrink-0 flex flex-col">
|
||||
<ProfileList />
|
||||
</div>
|
||||
|
||||
{/* Generator - Bottom Left */}
|
||||
<div className="shrink-0">
|
||||
<GenerationForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - History */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
<HistoryTable />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Audio Player - always visible except on settings */}
|
||||
{activeTab !== 'settings' && <AudioPlayer />}
|
||||
|
||||
{/* Show download toasts for any active downloads (from anywhere) */}
|
||||
{activeDownloads.map((download) => {
|
||||
const displayName = MODEL_DISPLAY_NAMES[download.model_name] || download.model_name;
|
||||
return (
|
||||
<DownloadToastRestorer
|
||||
key={download.model_name}
|
||||
modelName={download.model_name}
|
||||
displayName={displayName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that restores a download toast for a specific model.
|
||||
*/
|
||||
function DownloadToastRestorer({
|
||||
modelName,
|
||||
displayName,
|
||||
}: {
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
}) {
|
||||
// Use the download toast hook to restore the toast
|
||||
useModelDownloadToast({
|
||||
modelName,
|
||||
displayName,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
return null;
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useRouterState } from '@tanstack/react-router';
|
||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
||||
import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor';
|
||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import { useStory } from '@/lib/hooks/useStories';
|
||||
|
||||
interface AppFrameProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AppFrame({ children }: AppFrameProps) {
|
||||
const routerState = useRouterState();
|
||||
const isStoriesRoute = routerState.location.pathname === '/stories';
|
||||
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const { data: story } = useStory(selectedStoryId);
|
||||
|
||||
// Show track editor when on stories route with a selected story that has items
|
||||
const showTrackEditor = isStoriesRoute && selectedStoryId && story && story.items.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}>
|
||||
<TitleBarDragRegion />
|
||||
{children}
|
||||
{showTrackEditor ? (
|
||||
<StoryTrackEditor storyId={story.id} items={story.items} />
|
||||
) : (
|
||||
<AudioPlayer />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
import { debug } from '@/lib/utils/debug';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function AudioPlayer() {
|
||||
const platform = usePlatform();
|
||||
const volumeLabelId = useId();
|
||||
const {
|
||||
audioUrl,
|
||||
audioId,
|
||||
profileId,
|
||||
title,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
@@ -23,13 +30,47 @@ export function AudioPlayer() {
|
||||
setVolume,
|
||||
toggleLoop,
|
||||
clearRestartFlag,
|
||||
reset,
|
||||
} = usePlayerStore();
|
||||
|
||||
// Check if profile has assigned channels (for native audio routing)
|
||||
const { data: profileChannels } = useQuery({
|
||||
queryKey: ['profile-channels', profileId],
|
||||
queryFn: () => {
|
||||
if (!profileId) return { channel_ids: [] };
|
||||
return apiClient.getProfileChannels(profileId);
|
||||
},
|
||||
enabled: !!profileId && platform.metadata.isTauri,
|
||||
});
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ['channels'],
|
||||
queryFn: () => apiClient.listChannels(),
|
||||
enabled: !!profileChannels && profileChannels.channel_ids.length > 0,
|
||||
});
|
||||
|
||||
// Determine if we should use native playback
|
||||
const useNativePlayback = useMemo(() => {
|
||||
if (!platform.metadata.isTauri || !profileChannels || !channels) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const assignedChannels = channels.filter((ch) => profileChannels.channel_ids.includes(ch.id));
|
||||
|
||||
// Use native playback if any assigned channel has non-default devices
|
||||
const shouldUseNative = assignedChannels.some(
|
||||
(ch) => ch.device_ids.length > 0 && !ch.is_default,
|
||||
);
|
||||
|
||||
return shouldUseNative;
|
||||
}, [profileChannels, channels, profileId]);
|
||||
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
const previousAudioIdRef = useRef<string | null>(null);
|
||||
const hasInitializedRef = useRef(false);
|
||||
const isUsingNativePlaybackRef = useRef(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -41,11 +82,11 @@ export function AudioPlayer() {
|
||||
}
|
||||
|
||||
if (wavesurferRef.current) {
|
||||
console.log('WaveSurfer already initialized, skipping');
|
||||
debug.log('WaveSurfer already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Creating NEW WaveSurfer instance');
|
||||
debug.log('Creating NEW WaveSurfer instance');
|
||||
|
||||
// Wait for container to be properly rendered
|
||||
const initWaveSurfer = () => {
|
||||
@@ -71,7 +112,7 @@ export function AudioPlayer() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Initializing WaveSurfer...', {
|
||||
debug.log('Initializing WaveSurfer...', {
|
||||
container,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
@@ -104,9 +145,9 @@ export function AudioPlayer() {
|
||||
});
|
||||
|
||||
wavesurferRef.current = wavesurfer;
|
||||
console.log('WaveSurfer created successfully');
|
||||
debug.log('WaveSurfer created successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to create WaveSurfer:', error);
|
||||
debug.error('Failed to create WaveSurfer:', error);
|
||||
setError(
|
||||
`Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
@@ -122,47 +163,240 @@ export function AudioPlayer() {
|
||||
});
|
||||
|
||||
// Update store when duration is loaded
|
||||
wavesurfer.on('ready', () => {
|
||||
wavesurfer.on('ready', async () => {
|
||||
const dur = wavesurfer.getDuration();
|
||||
setDuration(dur);
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
console.log('Audio ready, duration:', dur);
|
||||
console.log('Waveform should be visible now');
|
||||
debug.log('Audio ready, duration:', dur);
|
||||
debug.log('Waveform should be visible now');
|
||||
|
||||
// Ensure volume is set
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
wavesurfer.setVolume(currentVolume);
|
||||
|
||||
// Get the underlying audio element and ensure it's not muted
|
||||
// (unless we're using native playback, which will be set later)
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
if (mediaElement && !isUsingNativePlaybackRef.current) {
|
||||
mediaElement.volume = currentVolume;
|
||||
mediaElement.muted = false;
|
||||
console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
||||
debug.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
||||
}
|
||||
|
||||
// Auto-play when ready
|
||||
// Use a small delay to ensure audio element is fully ready
|
||||
setTimeout(() => {
|
||||
wavesurfer.play().catch((error) => {
|
||||
console.error('Failed to autoplay:', error);
|
||||
// Don't show error for autoplay failures (browser restrictions)
|
||||
});
|
||||
}, 100);
|
||||
// Auto-play when ready - check if we should use native playback
|
||||
// Get current values from the store and queries at runtime (not captured closure values)
|
||||
const currentAudioUrl = usePlayerStore.getState().audioUrl;
|
||||
const currentProfileId = usePlayerStore.getState().profileId;
|
||||
|
||||
debug.log('Auto-play check - capturing runtime values...');
|
||||
|
||||
// Fetch profile channels at runtime (not using captured value)
|
||||
let runtimeProfileChannels = null;
|
||||
let runtimeChannels = null;
|
||||
|
||||
if (platform.metadata.isTauri && currentProfileId) {
|
||||
try {
|
||||
runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
|
||||
debug.log('Runtime profileChannels:', runtimeProfileChannels);
|
||||
|
||||
if (runtimeProfileChannels && runtimeProfileChannels.channel_ids.length > 0) {
|
||||
runtimeChannels = await apiClient.listChannels();
|
||||
debug.log('Runtime channels:', runtimeChannels);
|
||||
}
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch runtime channel data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
debug.log('Auto-play check:', {
|
||||
isTauri: platform.metadata.isTauri,
|
||||
currentAudioUrl,
|
||||
currentProfileId,
|
||||
hasProfileChannels: !!runtimeProfileChannels,
|
||||
hasChannels: !!runtimeChannels,
|
||||
});
|
||||
|
||||
if (
|
||||
platform.metadata.isTauri &&
|
||||
currentAudioUrl &&
|
||||
currentProfileId &&
|
||||
runtimeProfileChannels &&
|
||||
runtimeChannels
|
||||
) {
|
||||
debug.log('Attempting native audio playback...');
|
||||
|
||||
// Stop any existing native playback first
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
try {
|
||||
platform.audio.stopPlayback();
|
||||
debug.log('Stopped existing native playback before starting new one');
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop existing playback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Collect all device IDs from assigned channels
|
||||
const assignedChannels = runtimeChannels.filter((ch: any) =>
|
||||
runtimeProfileChannels.channel_ids.includes(ch.id),
|
||||
);
|
||||
debug.log('Assigned channels for playback:', assignedChannels);
|
||||
|
||||
// Check if any assigned channel has non-default devices
|
||||
const shouldUseNative = assignedChannels.some(
|
||||
(ch: any) => ch.device_ids.length > 0 && !ch.is_default,
|
||||
);
|
||||
debug.log('Should use native playback:', shouldUseNative);
|
||||
|
||||
if (!shouldUseNative) {
|
||||
debug.log('No custom devices assigned, falling back to WaveSurfer');
|
||||
// Reset native playback flag and unmute WaveSurfer
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
mediaElement.volume = currentVolume;
|
||||
mediaElement.muted = false;
|
||||
debug.log(
|
||||
'WaveSurfer unmuted for normal playback - volume:',
|
||||
mediaElement.volume,
|
||||
'muted:',
|
||||
mediaElement.muted,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
|
||||
debug.log('Device IDs to play to:', deviceIds);
|
||||
|
||||
if (deviceIds.length > 0) {
|
||||
debug.log('Fetching audio data from:', currentAudioUrl);
|
||||
// Fetch audio data
|
||||
const response = await fetch(currentAudioUrl);
|
||||
const audioData = new Uint8Array(await response.arrayBuffer());
|
||||
debug.log('Audio data size:', audioData.length);
|
||||
|
||||
// Play via native audio
|
||||
debug.log('Invoking play_audio_to_devices...');
|
||||
try {
|
||||
await platform.audio.playToDevices(audioData, deviceIds);
|
||||
debug.log('play_audio_to_devices completed successfully');
|
||||
|
||||
// Mark that we're using native playback
|
||||
isUsingNativePlaybackRef.current = true;
|
||||
|
||||
// Mute WaveSurfer's audio element to prevent UI audio output
|
||||
// Keep WaveSurfer running for visualization
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.volume = 0;
|
||||
mediaElement.muted = true;
|
||||
debug.log(
|
||||
'WaveSurfer muted for native playback - volume:',
|
||||
mediaElement.volume,
|
||||
'muted:',
|
||||
mediaElement.muted,
|
||||
);
|
||||
}
|
||||
|
||||
// Start WaveSurfer playback for visualization (muted)
|
||||
wavesurfer.play().catch((error) => {
|
||||
debug.error('Failed to start WaveSurfer visualization:', error);
|
||||
});
|
||||
|
||||
setIsPlaying(true);
|
||||
debug.log('Auto-playing via native audio routing - SUCCESS');
|
||||
return;
|
||||
} catch (invokeError) {
|
||||
debug.error('play_audio_to_devices invoke failed:', invokeError);
|
||||
throw invokeError;
|
||||
}
|
||||
} else {
|
||||
debug.log('No device IDs found, falling back to WaveSurfer');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debug.error(
|
||||
'Native playback failed during auto-play, falling back to WaveSurfer:',
|
||||
error,
|
||||
);
|
||||
// Reset native playback flag and unmute WaveSurfer
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
mediaElement.volume = currentVolume;
|
||||
mediaElement.muted = false;
|
||||
debug.log(
|
||||
'WaveSurfer unmuted after native playback failure - volume:',
|
||||
mediaElement.volume,
|
||||
'muted:',
|
||||
mediaElement.muted,
|
||||
);
|
||||
}
|
||||
// Fall through to WaveSurfer playback
|
||||
}
|
||||
} else {
|
||||
debug.log('Not using native playback, using WaveSurfer');
|
||||
// Reset native playback flag and unmute WaveSurfer
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
mediaElement.volume = currentVolume;
|
||||
mediaElement.muted = false;
|
||||
debug.log(
|
||||
'WaveSurfer unmuted for normal playback - volume:',
|
||||
mediaElement.volume,
|
||||
'muted:',
|
||||
mediaElement.muted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only auto-play if shouldAutoPlay flag is set (user explicitly clicked to play)
|
||||
const shouldAutoPlayNow = usePlayerStore.getState().shouldAutoPlay;
|
||||
if (shouldAutoPlayNow) {
|
||||
// Clear the flag first
|
||||
usePlayerStore.getState().clearAutoPlayFlag();
|
||||
|
||||
// Use a small delay to ensure audio element is fully ready
|
||||
setTimeout(() => {
|
||||
wavesurfer.play().catch((error) => {
|
||||
debug.error('Failed to autoplay:', error);
|
||||
// Don't show error for autoplay failures (browser restrictions)
|
||||
});
|
||||
}, 100);
|
||||
} else {
|
||||
debug.log('Skipping auto-play - shouldAutoPlay is false');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle play/pause
|
||||
wavesurfer.on('play', () => {
|
||||
setIsPlaying(true);
|
||||
// Ensure audio element is not muted when playing
|
||||
// Ensure audio element volume is set correctly
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.muted = false;
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
mediaElement.volume = currentVolume;
|
||||
console.log('Playing - volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
||||
// Double-check: if using native playback, keep WaveSurfer muted
|
||||
// Otherwise, ensure it's unmuted
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
mediaElement.volume = 0;
|
||||
mediaElement.muted = true;
|
||||
debug.log('Playing (native mode) - WaveSurfer muted for visualization only');
|
||||
} else {
|
||||
// Ensure WaveSurfer is unmuted for normal playback
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
mediaElement.volume = currentVolume;
|
||||
mediaElement.muted = false;
|
||||
debug.log(
|
||||
'Playing (normal mode) - volume:',
|
||||
mediaElement.volume,
|
||||
'muted:',
|
||||
mediaElement.muted,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
wavesurfer.on('pause', () => setIsPlaying(false));
|
||||
@@ -174,12 +408,17 @@ export function AudioPlayer() {
|
||||
wavesurfer.play();
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
// Trigger finish callback if set
|
||||
const onFinish = usePlayerStore.getState().onFinish;
|
||||
if (onFinish) {
|
||||
onFinish();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
wavesurfer.on('error', (error) => {
|
||||
console.error('WaveSurfer error:', error);
|
||||
debug.error('WaveSurfer error:', error);
|
||||
setIsLoading(false);
|
||||
setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
@@ -194,7 +433,7 @@ export function AudioPlayer() {
|
||||
|
||||
// Load audio immediately if audioUrl is already set
|
||||
if (audioUrl) {
|
||||
console.log('WaveSurfer ready, loading audio:', audioUrl);
|
||||
debug.log('WaveSurfer ready, loading audio:', audioUrl);
|
||||
loadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
// Stop any current playback before loading new audio
|
||||
@@ -204,11 +443,11 @@ export function AudioPlayer() {
|
||||
wavesurfer
|
||||
.load(audioUrl)
|
||||
.then(() => {
|
||||
console.log('Audio loaded into WaveSurfer');
|
||||
debug.log('Audio loaded into WaveSurfer');
|
||||
loadingRef.current = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load audio into WaveSurfer:', error);
|
||||
debug.error('Failed to load audio into WaveSurfer:', error);
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setError(
|
||||
@@ -233,12 +472,12 @@ export function AudioPlayer() {
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log('Cleaning up WaveSurfer initialization effect');
|
||||
debug.log('Cleaning up WaveSurfer initialization effect');
|
||||
if (rafId1) cancelAnimationFrame(rafId1);
|
||||
if (rafId2) cancelAnimationFrame(rafId2);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (wavesurferRef.current) {
|
||||
console.log('Destroying WaveSurfer instance');
|
||||
debug.log('Destroying WaveSurfer instance');
|
||||
try {
|
||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||
if (mediaElement) {
|
||||
@@ -247,7 +486,7 @@ export function AudioPlayer() {
|
||||
}
|
||||
wavesurferRef.current.destroy();
|
||||
} catch (error) {
|
||||
console.error('Error destroying WaveSurfer:', error);
|
||||
debug.error('Error destroying WaveSurfer:', error);
|
||||
}
|
||||
wavesurferRef.current = null;
|
||||
}
|
||||
@@ -268,36 +507,59 @@ export function AudioPlayer() {
|
||||
setDuration(0);
|
||||
setCurrentTime(0);
|
||||
setError(null);
|
||||
// Reset native playback flag
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop native playback if it was active
|
||||
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
|
||||
try {
|
||||
platform.audio.stopPlayback();
|
||||
debug.log('Stopped native audio playback');
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop native playback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset native playback flag when loading new audio
|
||||
// Also unmute WaveSurfer if it was muted
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.muted = false;
|
||||
mediaElement.volume = usePlayerStore.getState().volume;
|
||||
}
|
||||
}
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
|
||||
// CRITICAL: Force stop any current playback and cancel any pending loads
|
||||
// This must happen BEFORE any early returns
|
||||
console.log('Audio URL changed to:', audioUrl);
|
||||
debug.log('Audio URL changed to:', audioUrl);
|
||||
|
||||
// COMPLETELY stop and destroy the current audio
|
||||
try {
|
||||
// First pause if playing
|
||||
if (wavesurfer.isPlaying()) {
|
||||
console.log('Pausing current playback');
|
||||
debug.log('Pausing current playback');
|
||||
wavesurfer.pause();
|
||||
}
|
||||
|
||||
// Stop the media element explicitly
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
console.log('Stopping media element');
|
||||
debug.log('Stopping media element');
|
||||
mediaElement.pause();
|
||||
mediaElement.currentTime = 0;
|
||||
mediaElement.src = '';
|
||||
}
|
||||
|
||||
// Use empty() to completely destroy the waveform and media element
|
||||
console.log('Calling wavesurfer.empty() to destroy audio');
|
||||
debug.log('Calling wavesurfer.empty() to destroy audio');
|
||||
wavesurfer.empty();
|
||||
} catch (error) {
|
||||
console.error('Error stopping previous audio:', error);
|
||||
debug.error('Error stopping previous audio:', error);
|
||||
// Continue anyway to load new audio
|
||||
}
|
||||
|
||||
@@ -312,16 +574,16 @@ export function AudioPlayer() {
|
||||
setDuration(0);
|
||||
|
||||
// Load new audio
|
||||
console.log('Starting new audio load for:', audioUrl);
|
||||
debug.log('Starting new audio load for:', audioUrl);
|
||||
wavesurfer
|
||||
.load(audioUrl)
|
||||
.then(() => {
|
||||
console.log('Audio load promise resolved');
|
||||
debug.log('Audio load promise resolved');
|
||||
// Don't set loading to false here - wait for 'ready' event
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load audio:', error);
|
||||
console.error('Audio URL:', audioUrl);
|
||||
debug.error('Failed to load audio:', error);
|
||||
debug.error('Audio URL:', audioUrl);
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`);
|
||||
@@ -336,7 +598,7 @@ export function AudioPlayer() {
|
||||
if (isPlaying && wavesurferRef.current.isPlaying() === false) {
|
||||
// Only auto-play if audio is ready
|
||||
wavesurferRef.current.play().catch((error) => {
|
||||
console.error('Failed to play:', error);
|
||||
debug.error('Failed to play:', error);
|
||||
setIsPlaying(false);
|
||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
@@ -352,9 +614,16 @@ export function AudioPlayer() {
|
||||
// Also ensure the underlying audio element volume is set
|
||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.volume = volume;
|
||||
mediaElement.muted = volume === 0;
|
||||
console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
|
||||
// If using native playback, keep WaveSurfer muted regardless of volume setting
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
mediaElement.volume = 0;
|
||||
mediaElement.muted = true;
|
||||
debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
|
||||
} else {
|
||||
mediaElement.volume = volume;
|
||||
mediaElement.muted = volume === 0;
|
||||
debug.log('Volume synced:', volume, 'muted:', mediaElement.muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [volume]);
|
||||
@@ -381,38 +650,137 @@ export function AudioPlayer() {
|
||||
}
|
||||
|
||||
// Reset to beginning and play
|
||||
console.log('Restarting current audio from beginning');
|
||||
debug.log('Restarting current audio from beginning');
|
||||
wavesurfer.seekTo(0);
|
||||
wavesurfer.play().catch((error) => {
|
||||
console.error('Failed to play after restart:', error);
|
||||
debug.error('Failed to play after restart:', error);
|
||||
setIsPlaying(false);
|
||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
|
||||
|
||||
// Clear the restart flag
|
||||
clearRestartFlag();
|
||||
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
|
||||
|
||||
// Handle shouldAutoPlay flag - for story mode auto-advance
|
||||
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
|
||||
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
|
||||
|
||||
useEffect(() => {
|
||||
const wavesurfer = wavesurferRef.current;
|
||||
if (!wavesurfer || !shouldAutoPlay || duration === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-play the newly loaded audio
|
||||
debug.log('Auto-playing next track in story mode');
|
||||
wavesurfer.seekTo(0);
|
||||
wavesurfer.play().catch((error) => {
|
||||
debug.error('Failed to auto-play:', error);
|
||||
setIsPlaying(false);
|
||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
|
||||
// Clear the auto-play flag
|
||||
clearAutoPlayFlag();
|
||||
}, [shouldAutoPlay, duration, setIsPlaying, clearAutoPlayFlag]);
|
||||
|
||||
// Handle loop - WaveSurfer handles this via the 'finish' event
|
||||
|
||||
const handlePlayPause = () => {
|
||||
const handlePlayPause = async () => {
|
||||
// Standard WaveSurfer playback (works for both normal and native playback modes)
|
||||
// When using native playback, WaveSurfer is muted but still controls visualization
|
||||
if (!wavesurferRef.current) {
|
||||
console.error('WaveSurfer not initialized');
|
||||
debug.error('WaveSurfer not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if audio is loaded
|
||||
if (duration === 0 && !isLoading) {
|
||||
console.error('Audio not loaded yet');
|
||||
debug.error('Audio not loaded yet');
|
||||
setError('Audio not loaded. Please wait...');
|
||||
return;
|
||||
}
|
||||
|
||||
// If using native playback
|
||||
if (useNativePlayback && audioUrl && profileChannels && channels) {
|
||||
if (isPlaying) {
|
||||
// Pause: stop native playback and pause WaveSurfer visualization
|
||||
try {
|
||||
platform.audio.stopPlayback();
|
||||
debug.log('Stopped native audio playback');
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop native playback:', error);
|
||||
}
|
||||
wavesurferRef.current.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Play: trigger native playback
|
||||
try {
|
||||
// Stop any existing native playback first
|
||||
try {
|
||||
platform.audio.stopPlayback();
|
||||
} catch (_error) {
|
||||
// Ignore errors when stopping (might not be playing)
|
||||
debug.log('No existing playback to stop');
|
||||
}
|
||||
|
||||
// Collect all device IDs from assigned channels
|
||||
const assignedChannels = channels.filter((ch) =>
|
||||
profileChannels.channel_ids.includes(ch.id),
|
||||
);
|
||||
const deviceIds = assignedChannels.flatMap((ch) => ch.device_ids);
|
||||
|
||||
if (deviceIds.length > 0) {
|
||||
// Fetch audio data
|
||||
const response = await fetch(audioUrl);
|
||||
const audioData = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
// Play via native audio
|
||||
await platform.audio.playToDevices(audioData, deviceIds);
|
||||
|
||||
// Mark that we're using native playback
|
||||
isUsingNativePlaybackRef.current = true;
|
||||
|
||||
// Mute WaveSurfer and start it for visualization
|
||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.volume = 0;
|
||||
mediaElement.muted = true;
|
||||
}
|
||||
|
||||
// Start WaveSurfer for visualization (muted)
|
||||
wavesurferRef.current.play().catch((error) => {
|
||||
debug.error('Failed to start WaveSurfer visualization:', error);
|
||||
setIsPlaying(false);
|
||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
debug.error('Native playback failed, falling back to WaveSurfer:', error);
|
||||
// Fall through to WaveSurfer playback
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Standard WaveSurfer playback (or fallback from native playback failure)
|
||||
if (wavesurferRef.current.isPlaying()) {
|
||||
wavesurferRef.current.pause();
|
||||
} else {
|
||||
// Ensure WaveSurfer is not muted if not using native playback
|
||||
if (!isUsingNativePlaybackRef.current) {
|
||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.muted = false;
|
||||
mediaElement.volume = volume;
|
||||
}
|
||||
}
|
||||
|
||||
wavesurferRef.current.play().catch((error) => {
|
||||
console.error('Failed to play:', error);
|
||||
debug.error('Failed to play:', error);
|
||||
setIsPlaying(false);
|
||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
@@ -429,6 +797,24 @@ export function AudioPlayer() {
|
||||
setVolume(value[0] / 100);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Stop any native playback
|
||||
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
|
||||
try {
|
||||
platform.audio.stopPlayback();
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop native playback:', error);
|
||||
}
|
||||
}
|
||||
// Stop WaveSurfer
|
||||
if (wavesurferRef.current) {
|
||||
wavesurferRef.current.pause();
|
||||
wavesurferRef.current.seekTo(0);
|
||||
}
|
||||
// Reset player state
|
||||
reset();
|
||||
};
|
||||
|
||||
// Don't render if no audio
|
||||
if (!audioUrl) {
|
||||
return null;
|
||||
@@ -446,6 +832,13 @@ export function AudioPlayer() {
|
||||
disabled={isLoading || duration === 0}
|
||||
className="shrink-0"
|
||||
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
|
||||
aria-label={
|
||||
duration === 0 && !isLoading
|
||||
? 'Audio not loaded'
|
||||
: isPlaying
|
||||
? 'Pause'
|
||||
: 'Play'
|
||||
}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
</Button>
|
||||
@@ -460,6 +853,8 @@ export function AudioPlayer() {
|
||||
max={100}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
aria-label="Playback position"
|
||||
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
@@ -487,28 +882,47 @@ export function AudioPlayer() {
|
||||
onClick={toggleLoop}
|
||||
className={isLooping ? 'text-primary' : ''}
|
||||
title="Toggle loop"
|
||||
aria-label={isLooping ? 'Stop looping' : 'Loop'}
|
||||
>
|
||||
<Repeat className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center gap-2 shrink-0 w-[120px]">
|
||||
<div className="flex items-center gap-2 shrink-0 w-[120px]" role="group" aria-label="Volume">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setVolume(volume > 0 ? 0 : 1)}
|
||||
className="h-8 w-8"
|
||||
aria-label={volume > 0 ? 'Mute' : 'Unmute'}
|
||||
>
|
||||
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
||||
</Button>
|
||||
<span id={volumeLabelId} className="sr-only">
|
||||
Volume level, {Math.round(volume * 100)}%
|
||||
</span>
|
||||
<Slider
|
||||
value={[volume * 100]}
|
||||
onValueChange={handleVolumeChange}
|
||||
max={100}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
aria-labelledby={volumeLabelId}
|
||||
aria-valuetext={`${Math.round(volume * 100)}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleClose}
|
||||
className="shrink-0"
|
||||
title="Close player"
|
||||
aria-label="Close player"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
interface AudioDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export function AudioTab() {
|
||||
const platform = usePlatform();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
|
||||
const { data: channels, isLoading: channelsLoading } = useQuery({
|
||||
queryKey: ['channels'],
|
||||
queryFn: () => apiClient.listChannels(),
|
||||
});
|
||||
|
||||
const { data: devices, isLoading: devicesLoading } = useQuery({
|
||||
queryKey: ['audio-devices'],
|
||||
queryFn: async () => {
|
||||
if (!platform.metadata.isTauri) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return await platform.audio.listOutputDevices();
|
||||
} catch (error) {
|
||||
console.error('Failed to list audio devices:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: platform.metadata.isTauri,
|
||||
});
|
||||
|
||||
const { data: profiles } = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
queryFn: () => apiClient.listProfiles(),
|
||||
});
|
||||
|
||||
const createChannel = useMutation({
|
||||
mutationFn: (data: { name: string; device_ids: string[] }) => apiClient.createChannel(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||
setCreateDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const updateChannel = useMutation({
|
||||
mutationFn: ({
|
||||
channelId,
|
||||
data,
|
||||
}: {
|
||||
channelId: string;
|
||||
data: { name?: string; device_ids?: string[] };
|
||||
}) => apiClient.updateChannel(channelId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||
setEditingChannel(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteChannel = useMutation({
|
||||
mutationFn: (channelId: string) => apiClient.deleteChannel(channelId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||
},
|
||||
});
|
||||
|
||||
const { data: channelVoices } = useQuery({
|
||||
queryKey: ['channel-voices', editingChannel],
|
||||
queryFn: async () => {
|
||||
if (!editingChannel) return { profile_ids: [] };
|
||||
return apiClient.getChannelVoices(editingChannel);
|
||||
},
|
||||
enabled: !!editingChannel,
|
||||
});
|
||||
|
||||
const setChannelVoices = useMutation({
|
||||
mutationFn: ({ channelId, profileIds }: { channelId: string; profileIds: string[] }) =>
|
||||
apiClient.setChannelVoices(channelId, profileIds),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channel-voices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (channelsLoading || devicesLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleChannelDelete = async (e, channelId) => {
|
||||
e.stopPropagation();
|
||||
if (await confirm('Delete this channel?')) {
|
||||
deleteChannel.mutate(channelId);
|
||||
}
|
||||
};
|
||||
|
||||
const allChannels = channels || [];
|
||||
const allDevices = devices || [];
|
||||
const selectedChannel = selectedChannelId
|
||||
? allChannels.find((c) => c.id === selectedChannelId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6 shrink-0">
|
||||
<h2 className="text-2xl font-bold">Audio Channels</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Channel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0">
|
||||
{/* Left Column - Channels */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col min-h-0 overflow-y-auto',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
{allChannels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||
<Speaker className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No audio channels yet. Create your first channel to route voices to specific
|
||||
devices.
|
||||
</p>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Channel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{allChannels.map((channel) => {
|
||||
const isSelected = selectedChannelId === channel.id;
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'group border rounded-lg p-4 transition-colors cursor-pointer text-left w-full',
|
||||
isSelected && 'ring-2 ring-primary bg-primary/5 border-primary',
|
||||
)}
|
||||
onClick={() => setSelectedChannelId(isSelected ? null : channel.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Speaker className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 ml-10">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Output Devices
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{channel.device_ids.length > 0
|
||||
? channel.device_ids.map((deviceId) => {
|
||||
const device = allDevices.find((d) => d.id === deviceId);
|
||||
return (
|
||||
<Badge
|
||||
key={deviceId}
|
||||
variant="outline"
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
{device?.name || deviceId}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
: (() => {
|
||||
const defaultDevice = allDevices.find((d) => d.is_default);
|
||||
return defaultDevice ? (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{defaultDevice.name}
|
||||
</Badge>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Assigned Voices
|
||||
</div>
|
||||
<ChannelVoicesList channelId={channel.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!channel.is_default && (
|
||||
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingChannel(channel.id);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => handleChannelDelete(e, channel.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Available Devices */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col min-h-0 overflow-y-auto',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 mb-4">
|
||||
<h3 className="text-lg font-semibold">Available Devices</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{selectedChannelId
|
||||
? selectedChannel?.is_default
|
||||
? 'Default channel uses system default device'
|
||||
: 'Click devices to add or remove them from the selected channel'
|
||||
: 'Select a channel to assign devices'}
|
||||
</p>
|
||||
</div>
|
||||
{allDevices.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{allDevices.map((device) => {
|
||||
const isConnected =
|
||||
selectedChannelId &&
|
||||
selectedChannel &&
|
||||
(selectedChannel.device_ids.length === 0
|
||||
? device.is_default
|
||||
: selectedChannel.device_ids.includes(device.id));
|
||||
const canToggle =
|
||||
selectedChannelId && selectedChannel && !selectedChannel.is_default;
|
||||
|
||||
const handleDeviceClick = () => {
|
||||
if (!canToggle || !selectedChannel) return;
|
||||
|
||||
const currentDeviceIds = selectedChannel.device_ids;
|
||||
const newDeviceIds = isConnected
|
||||
? currentDeviceIds.filter((id) => id !== device.id)
|
||||
: [...currentDeviceIds, device.id];
|
||||
|
||||
updateChannel.mutate({
|
||||
channelId: selectedChannelId,
|
||||
data: { device_ids: newDeviceIds },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
key={device.id}
|
||||
type="button"
|
||||
onClick={handleDeviceClick}
|
||||
disabled={!canToggle}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm p-3 rounded-lg border transition-colors text-left w-full',
|
||||
isConnected
|
||||
? 'bg-primary/10 border-primary ring-1 ring-primary/20'
|
||||
: 'hover:bg-muted/50',
|
||||
!canToggle && 'cursor-default opacity-60',
|
||||
canToggle && 'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{canToggle ? (
|
||||
<div
|
||||
className={cn(
|
||||
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0',
|
||||
isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30',
|
||||
)}
|
||||
>
|
||||
{isConnected && <Check className="h-3 w-3 text-accent-foreground" />}
|
||||
</div>
|
||||
) : device.is_default ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
|
||||
) : null}
|
||||
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
|
||||
{device.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
{platform.metadata.isTauri
|
||||
? 'No audio devices found'
|
||||
: 'Audio device selection requires Tauri'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Channel Dialog */}
|
||||
<CreateChannelDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
devices={devices || []}
|
||||
onCreate={(name, deviceIds) => {
|
||||
createChannel.mutate({ name, device_ids: deviceIds });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Edit Channel Dialog */}
|
||||
{editingChannel &&
|
||||
(() => {
|
||||
const channel = channels?.find((c) => c.id === editingChannel);
|
||||
return channel ? (
|
||||
<EditChannelDialog
|
||||
open={!!editingChannel}
|
||||
onOpenChange={(open) => !open && setEditingChannel(null)}
|
||||
channel={channel}
|
||||
devices={devices || []}
|
||||
profiles={profiles || []}
|
||||
channelVoices={channelVoices?.profile_ids || []}
|
||||
onUpdate={(name, deviceIds) => {
|
||||
updateChannel.mutate({
|
||||
channelId: editingChannel,
|
||||
data: { name, device_ids: deviceIds },
|
||||
});
|
||||
}}
|
||||
onSetVoices={(profileIds) => {
|
||||
setChannelVoices.mutate({
|
||||
channelId: editingChannel,
|
||||
profileIds,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelVoicesList({ channelId }: { channelId: string }) {
|
||||
const { data: voices } = useQuery({
|
||||
queryKey: ['channel-voices', channelId],
|
||||
queryFn: () => apiClient.getChannelVoices(channelId),
|
||||
});
|
||||
|
||||
const { data: profiles } = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
queryFn: () => apiClient.listProfiles(),
|
||||
});
|
||||
|
||||
const voiceNames =
|
||||
voices?.profile_ids.map((id) => profiles?.find((p) => p.id === id)?.name).filter(Boolean) || [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{voiceNames.length > 0 ? (
|
||||
voiceNames.map((name) => (
|
||||
<Badge key={name} variant="outline" className="text-xs font-normal">
|
||||
{name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">No voices assigned</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateChannelDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
devices: AudioDevice[];
|
||||
onCreate: (name: string, deviceIds: string[]) => void;
|
||||
}
|
||||
|
||||
function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (name.trim()) {
|
||||
onCreate(name.trim(), selectedDevices);
|
||||
setName('');
|
||||
setSelectedDevices([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Audio Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new audio channel (bus) to route voices to specific output devices.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Input
|
||||
id="channel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Virtual Cable, Broadcast"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Output Devices</Label>
|
||||
<Select
|
||||
value={selectedDevices[0] || ''}
|
||||
onValueChange={(value) => {
|
||||
if (value && !selectedDevices.includes(value)) {
|
||||
setSelectedDevices([...selectedDevices, value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select device" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{devices.map((device) => (
|
||||
<SelectItem key={device.id} value={device.id}>
|
||||
{device.name} {device.is_default && '(default)'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedDevices.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{selectedDevices.map((deviceId) => {
|
||||
const device = devices.find((d) => d.id === deviceId);
|
||||
return (
|
||||
<div
|
||||
key={deviceId}
|
||||
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||
>
|
||||
<span>{device?.name || deviceId}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditChannelDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
channel: {
|
||||
id: string;
|
||||
name: string;
|
||||
device_ids: string[];
|
||||
};
|
||||
devices: AudioDevice[];
|
||||
profiles: Array<{ id: string; name: string }>;
|
||||
channelVoices: string[];
|
||||
onUpdate: (name: string, deviceIds: string[]) => void;
|
||||
onSetVoices: (profileIds: string[]) => void;
|
||||
}
|
||||
|
||||
function EditChannelDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
channel,
|
||||
devices,
|
||||
profiles,
|
||||
channelVoices,
|
||||
onUpdate,
|
||||
onSetVoices,
|
||||
}: EditChannelDialogProps) {
|
||||
const [name, setName] = useState(channel.name);
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>(channel.device_ids);
|
||||
const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (name.trim()) {
|
||||
onUpdate(name.trim(), selectedDevices);
|
||||
onSetVoices(selectedVoices);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Channel</DialogTitle>
|
||||
<DialogDescription>Update channel settings and voice assignments.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-channel-name">Channel Name</Label>
|
||||
<Input id="edit-channel-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Output Devices</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
if (value && !selectedDevices.includes(value)) {
|
||||
setSelectedDevices([...selectedDevices, value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Add device" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{devices.map((device) => (
|
||||
<SelectItem key={device.id} value={device.id}>
|
||||
{device.name} {device.is_default && '(default)'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedDevices.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{selectedDevices.map((deviceId) => {
|
||||
const device = devices.find((d) => d.id === deviceId);
|
||||
return (
|
||||
<div
|
||||
key={deviceId}
|
||||
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||
>
|
||||
<span>{device?.name || deviceId}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Assigned Voices</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
if (value && !selectedVoices.includes(value)) {
|
||||
setSelectedVoices([...selectedVoices, value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Add voice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedVoices.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{selectedVoices.map((profileId) => {
|
||||
const profile = profiles.find((p) => p.id === profileId);
|
||||
return (
|
||||
<div
|
||||
key={profileId}
|
||||
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||
>
|
||||
<span>{profile?.name || profileId}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# Voice generation components
|
||||
@@ -0,0 +1,513 @@
|
||||
import { useMatchRoute } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
|
||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { useAddStoryItem, useStory } from '@/lib/hooks/useStories';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { ParalinguisticInput } from './ParalinguisticInput';
|
||||
|
||||
interface FloatingGenerateBoxProps {
|
||||
isPlayerOpen?: boolean;
|
||||
showVoiceSelector?: boolean;
|
||||
}
|
||||
|
||||
export function FloatingGenerateBox({
|
||||
isPlayerOpen = false,
|
||||
showVoiceSelector = false,
|
||||
}: FloatingGenerateBoxProps) {
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||
const { data: profiles } = useProfiles();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isInstructMode, setIsInstructMode] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const matchRoute = useMatchRoute();
|
||||
const isStoriesRoute = matchRoute({ to: '/stories' });
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||
const { data: currentStory } = useStory(selectedStoryId);
|
||||
const addStoryItem = useAddStoryItem();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Calculate if track editor is visible (on stories route with items)
|
||||
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
||||
|
||||
const { form, handleSubmit, isPending } = useGenerationForm({
|
||||
onSuccess: async (generationId) => {
|
||||
setIsExpanded(false);
|
||||
// If on stories route and a story is selected, add generation to story
|
||||
if (isStoriesRoute && selectedStoryId && generationId) {
|
||||
try {
|
||||
await addStoryItem.mutateAsync({
|
||||
storyId: selectedStoryId,
|
||||
data: { generation_id: generationId },
|
||||
});
|
||||
toast({
|
||||
title: 'Added to story',
|
||||
description: `Generation added to "${currentStory?.name || 'story'}"`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to add to story',
|
||||
description:
|
||||
error instanceof Error ? error.message : 'Could not add generation to story',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Click away handler to collapse the box
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Don't collapse if clicking inside the container
|
||||
if (containerRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't collapse if clicking on a Select dropdown (which renders in a portal)
|
||||
if (
|
||||
target.closest('[role="listbox"]') ||
|
||||
target.closest('[data-radix-popper-content-wrapper]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExpanded(false);
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isExpanded]);
|
||||
|
||||
// Set first voice as default if none selected
|
||||
useEffect(() => {
|
||||
if (!selectedProfileId && profiles && profiles.length > 0) {
|
||||
setSelectedProfileId(profiles[0].id);
|
||||
}
|
||||
}, [selectedProfileId, profiles, setSelectedProfileId]);
|
||||
|
||||
// Sync generation form language with selected profile's language
|
||||
useEffect(() => {
|
||||
if (selectedProfile?.language) {
|
||||
form.setValue('language', selectedProfile.language as LanguageCode);
|
||||
}
|
||||
}, [selectedProfile, form]);
|
||||
|
||||
// Auto-resize textarea based on content (only when expanded)
|
||||
useEffect(() => {
|
||||
if (!isExpanded) {
|
||||
// Reset textarea height after collapse animation completes
|
||||
const timeoutId = setTimeout(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = '32px';
|
||||
textarea.style.overflowY = 'hidden';
|
||||
}
|
||||
}, 200); // Wait for animation to complete
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const adjustHeight = () => {
|
||||
textarea.style.height = 'auto';
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
const minHeight = 100; // Expanded minimum
|
||||
const maxHeight = 300; // Max height in pixels
|
||||
const targetHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight));
|
||||
textarea.style.height = `${targetHeight}px`;
|
||||
|
||||
// Show scrollbar if content exceeds max height
|
||||
if (scrollHeight > maxHeight) {
|
||||
textarea.style.overflowY = 'auto';
|
||||
} else {
|
||||
textarea.style.overflowY = 'hidden';
|
||||
}
|
||||
};
|
||||
|
||||
// Small delay to let framer animation complete
|
||||
const timeoutId = setTimeout(() => {
|
||||
adjustHeight();
|
||||
}, 200);
|
||||
|
||||
// Adjust on mount and when value changes
|
||||
adjustHeight();
|
||||
|
||||
// Watch for input changes
|
||||
textarea.addEventListener('input', adjustHeight);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
textarea.removeEventListener('input', adjustHeight);
|
||||
};
|
||||
}, [isExpanded]);
|
||||
|
||||
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
|
||||
await handleSubmit(data, selectedProfileId);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'fixed right-auto',
|
||||
isStoriesRoute
|
||||
? // Position aligned with story list: after sidebar + padding, width 360px
|
||||
'left-[calc(5rem+2rem)] w-[360px]'
|
||||
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||
)}
|
||||
style={{
|
||||
// On stories route: offset by track editor height when visible
|
||||
// On other routes: offset by audio player height when visible
|
||||
bottom: hasTrackEditor
|
||||
? `${trackEditorHeight + 24}px`
|
||||
: isPlayerOpen
|
||||
? 'calc(7rem + 1.5rem)'
|
||||
: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 p-3"
|
||||
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex gap-2">
|
||||
<motion.div
|
||||
className={cn('flex-1', isExpanded && 'mr-12')}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
>
|
||||
{/* Text field - hidden when in instruct mode */}
|
||||
<div style={{ display: isInstructMode ? 'none' : 'block' }}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<motion.div
|
||||
animate={{
|
||||
height: isExpanded ? 'auto' : '32px',
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
{form.watch('engine') === 'chatterbox_turbo' ? (
|
||||
<ParalinguisticInput
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={
|
||||
isStoriesRoute && currentStory
|
||||
? `Generate speech for "${currentStory.name}"... (type / for effects)`
|
||||
: selectedProfile
|
||||
? `Type / for effects like [laugh], [sigh]...`
|
||||
: 'Select a voice profile above...'
|
||||
}
|
||||
className="px-3 py-2 resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm w-full"
|
||||
style={{
|
||||
minHeight: isExpanded ? '100px' : '32px',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
disabled={!selectedProfileId}
|
||||
onClick={() => setIsExpanded(true)}
|
||||
onFocus={() => setIsExpanded(true)}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
{...field}
|
||||
ref={(node: HTMLTextAreaElement | null) => {
|
||||
// Store ref for auto-resize (only for active field)
|
||||
if (!isInstructMode) {
|
||||
textareaRef.current = node;
|
||||
}
|
||||
// Forward ref to react-hook-form
|
||||
if (typeof field.ref === 'function') {
|
||||
field.ref(node);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
isStoriesRoute && currentStory
|
||||
? `Generate speech for "${currentStory.name}"...`
|
||||
: selectedProfile
|
||||
? `Generate speech using ${selectedProfile.name}...`
|
||||
: 'Select a voice profile above...'
|
||||
}
|
||||
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
||||
style={{
|
||||
minHeight: isExpanded ? '100px' : '32px',
|
||||
maxHeight: '300px',
|
||||
}}
|
||||
disabled={!selectedProfileId}
|
||||
onClick={() => setIsExpanded(true)}
|
||||
onFocus={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Instruct field - hidden when in text mode */}
|
||||
<div style={{ display: isInstructMode ? 'block' : 'none' }}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="instruct"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<motion.div
|
||||
animate={{
|
||||
height: isExpanded ? 'auto' : '32px',
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<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="e.g. very happy and excited"
|
||||
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
||||
style={{
|
||||
minHeight: isExpanded ? '100px' : '32px',
|
||||
maxHeight: '300px',
|
||||
}}
|
||||
disabled={!selectedProfileId}
|
||||
onClick={() => setIsExpanded(true)}
|
||||
onFocus={() => setIsExpanded(true)}
|
||||
/>
|
||||
</motion.div>
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !selectedProfileId}
|
||||
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
|
||||
size="icon"
|
||||
aria-label={
|
||||
isPending
|
||||
? 'Generating...'
|
||||
: !selectedProfileId
|
||||
? 'Select a voice profile first'
|
||||
: 'Generate speech'
|
||||
}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
{isPending
|
||||
? 'Generating...'
|
||||
: !selectedProfileId
|
||||
? 'Select a voice profile first'
|
||||
: 'Generate speech'}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isExpanded && form.watch('engine') === 'qwen' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute top-0 right-[calc(100%+0.5rem)]"
|
||||
>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsInstructMode(!isInstructMode)}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-full transition-all duration-200',
|
||||
isInstructMode
|
||||
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||
: 'bg-card border border-border hover:bg-background/50',
|
||||
)}
|
||||
aria-label={
|
||||
isInstructMode ? 'Fine tune instructions, on' : 'Fine tune instructions'
|
||||
}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
Fine tune instructions
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className=" mt-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{showVoiceSelector && (
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={selectedProfileId || ''}
|
||||
onValueChange={(value) => setSelectedProfileId(value || null)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all w-full">
|
||||
<SelectValue placeholder="Select a voice..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles?.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id} className="text-xs">
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => {
|
||||
const engineLangs = getLanguageOptionsForEngine(
|
||||
form.watch('engine') || 'qwen',
|
||||
);
|
||||
return (
|
||||
<FormItem className="flex-1 space-y-0">
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{engineLangs.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value} className="text-xs">
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormItem className="flex-1 space-y-0">
|
||||
<Select
|
||||
value={
|
||||
form.watch('engine') === 'luxtts'
|
||||
? 'luxtts'
|
||||
: form.watch('engine') === 'chatterbox'
|
||||
? 'chatterbox'
|
||||
: form.watch('engine') === 'chatterbox_turbo'
|
||||
? 'chatterbox_turbo'
|
||||
: `qwen:${form.watch('modelSize') || '1.7B'}`
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'luxtts') {
|
||||
form.setValue('engine', 'luxtts');
|
||||
form.setValue('language', 'en');
|
||||
} else if (value === 'chatterbox') {
|
||||
form.setValue('engine', 'chatterbox');
|
||||
} else if (value === 'chatterbox_turbo') {
|
||||
form.setValue('engine', 'chatterbox_turbo');
|
||||
form.setValue('language', 'en');
|
||||
} else {
|
||||
const [, modelSize] = value.split(':');
|
||||
form.setValue('engine', 'qwen');
|
||||
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="qwen:1.7B" className="text-xs text-muted-foreground">
|
||||
Qwen3-TTS 1.7B
|
||||
</SelectItem>
|
||||
<SelectItem value="qwen:0.6B" className="text-xs text-muted-foreground">
|
||||
Qwen3-TTS 0.6B
|
||||
</SelectItem>
|
||||
<SelectItem value="luxtts" className="text-xs text-muted-foreground">
|
||||
LuxTTS
|
||||
</SelectItem>
|
||||
<SelectItem value="chatterbox" className="text-xs text-muted-foreground">
|
||||
Chatterbox
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="chatterbox_turbo"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Chatterbox Turbo
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</Form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2, Mic } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -23,118 +19,20 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
||||
import { useGeneration } from '@/lib/hooks/useGeneration';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
|
||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
const generationSchema = z.object({
|
||||
text: z.string().min(1, 'Text is required').max(5000),
|
||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||
seed: z.number().int().optional(),
|
||||
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
||||
instruct: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
type GenerationFormValues = z.infer<typeof generationSchema>;
|
||||
import { ParalinguisticInput } from './ParalinguisticInput';
|
||||
|
||||
export function GenerationForm() {
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||
const generation = useGeneration();
|
||||
const { toast } = useToast();
|
||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
||||
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
||||
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||
|
||||
// Use the download toast hook to show progress when model is downloading
|
||||
useModelDownloadToast({
|
||||
modelName: downloadingModelName || '',
|
||||
displayName: downloadingDisplayName || '',
|
||||
enabled: !!downloadingModelName,
|
||||
});
|
||||
const { form, handleSubmit, isPending } = useGenerationForm();
|
||||
|
||||
const form = useForm<GenerationFormValues>({
|
||||
resolver: zodResolver(generationSchema),
|
||||
defaultValues: {
|
||||
text: '',
|
||||
language: 'en',
|
||||
seed: undefined,
|
||||
modelSize: '1.7B',
|
||||
instruct: '',
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: GenerationFormValues) {
|
||||
if (!selectedProfileId) {
|
||||
toast({
|
||||
title: 'No profile selected',
|
||||
description: 'Please select a voice profile from the cards above.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
// Determine model name and display name
|
||||
const modelName = `qwen-tts-${data.modelSize}`;
|
||||
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
|
||||
|
||||
// Check if model is downloaded before starting generation
|
||||
try {
|
||||
const modelStatus = await apiClient.getModelStatus();
|
||||
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
||||
|
||||
if (model && !model.downloaded) {
|
||||
// Model is not downloaded, enable download toast
|
||||
setDownloadingModelName(modelName);
|
||||
setDownloadingDisplayName(displayName);
|
||||
}
|
||||
} catch (error) {
|
||||
// If status check fails, continue anyway - generation will handle it
|
||||
console.error('Failed to check model status:', error);
|
||||
}
|
||||
|
||||
// Proceed with generation (which will trigger download if needed)
|
||||
const result = await generation.mutateAsync({
|
||||
profile_id: selectedProfileId,
|
||||
text: data.text,
|
||||
language: data.language,
|
||||
seed: data.seed,
|
||||
model_size: data.modelSize,
|
||||
instruct: data.instruct || undefined,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Generation complete!',
|
||||
description: `Audio generated (${result.duration.toFixed(2)}s)`,
|
||||
});
|
||||
|
||||
// Autoplay the generated audio
|
||||
const audioUrl = apiClient.getAudioUrl(result.id);
|
||||
setAudio(audioUrl, result.id, data.text.substring(0, 50));
|
||||
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Generation failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to generate audio',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
// Clear download state after generation completes
|
||||
setDownloadingModelName(null);
|
||||
setDownloadingDisplayName(null);
|
||||
}
|
||||
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
|
||||
await handleSubmit(data, selectedProfileId);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -167,87 +65,134 @@ export function GenerationForm() {
|
||||
<FormItem>
|
||||
<FormLabel>Text to Speak</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter the text you want to generate..."
|
||||
className="min-h-[150px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Max 5000 characters</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="instruct"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Delivery Instructions (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
{form.watch('engine') === 'chatterbox_turbo' ? (
|
||||
<ParalinguisticInput
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Enter text... type / for effects like [laugh], [sigh]"
|
||||
className="min-h-[150px] rounded-md border border-input bg-background px-3 py-2"
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder="Enter the text you want to generate..."
|
||||
className="min-h-[150px]"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Natural language instructions to control speech delivery (tone, emotion, pace).
|
||||
Max 500 characters
|
||||
{form.watch('engine') === 'chatterbox_turbo'
|
||||
? 'Max 5000 characters. Type / to insert sound effects.'
|
||||
: 'Max 5000 characters'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{form.watch('engine') === 'qwen' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
name="instruct"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormLabel>Delivery Instructions (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Natural language instructions to control speech delivery (tone, emotion,
|
||||
pace). Max 500 characters
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<FormItem>
|
||||
<FormLabel>Model</FormLabel>
|
||||
<Select
|
||||
value={
|
||||
form.watch('engine') === 'luxtts'
|
||||
? 'luxtts'
|
||||
: form.watch('engine') === 'chatterbox'
|
||||
? 'chatterbox'
|
||||
: form.watch('engine') === 'chatterbox_turbo'
|
||||
? 'chatterbox_turbo'
|
||||
: `qwen:${form.watch('modelSize') || '1.7B'}`
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'luxtts') {
|
||||
form.setValue('engine', 'luxtts');
|
||||
form.setValue('language', 'en');
|
||||
} else if (value === 'chatterbox') {
|
||||
form.setValue('engine', 'chatterbox');
|
||||
} else if (value === 'chatterbox_turbo') {
|
||||
form.setValue('engine', 'chatterbox_turbo');
|
||||
form.setValue('language', 'en');
|
||||
} else {
|
||||
const [, modelSize] = value.split(':');
|
||||
form.setValue('engine', 'qwen');
|
||||
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="qwen:1.7B">Qwen3-TTS 1.7B</SelectItem>
|
||||
<SelectItem value="qwen:0.6B">Qwen3-TTS 0.6B</SelectItem>
|
||||
<SelectItem value="luxtts">LuxTTS</SelectItem>
|
||||
<SelectItem value="chatterbox">Chatterbox</SelectItem>
|
||||
<SelectItem value="chatterbox_turbo">Chatterbox Turbo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{form.watch('engine') === 'luxtts'
|
||||
? 'Fast, English-focused'
|
||||
: form.watch('engine') === 'chatterbox'
|
||||
? '23 languages, incl. Hebrew'
|
||||
: form.watch('engine') === 'chatterbox_turbo'
|
||||
? 'English, [laugh] [cough] tags'
|
||||
: 'Multi-language, two sizes'}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="modelSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Model Size</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="1.7B">Qwen TTS 1.7B (Higher Quality)</SelectItem>
|
||||
<SelectItem value="0.6B">Qwen TTS 0.6B (Faster)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Larger models produce better quality</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name="language"
|
||||
render={({ field }) => {
|
||||
const engineLangs = getLanguageOptionsForEngine(form.watch('engine') || 'qwen');
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{engineLangs.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
@@ -273,12 +218,8 @@ export function GenerationForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={generation.isPending || !selectedProfileId}
|
||||
>
|
||||
{generation.isPending ? (
|
||||
<Button type="submit" className="w-full" disabled={isPending || !selectedProfileId}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating...
|
||||
|
||||
@@ -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,4 +1,12 @@
|
||||
import { AudioWaveform, Download, FileArchive, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
AudioWaveform,
|
||||
Download,
|
||||
FileArchive,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -16,7 +24,10 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { HistoryResponse } from '@/lib/api/types';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import {
|
||||
useDeleteGeneration,
|
||||
useExportGeneration,
|
||||
@@ -31,17 +42,27 @@ import { usePlayerStore } from '@/stores/playerStore';
|
||||
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
||||
// This is the new alternate history view with fixed height rows
|
||||
|
||||
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS
|
||||
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
|
||||
export function HistoryTable() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [allHistory, setAllHistory] = useState<HistoryResponse[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
const limit = 20;
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: historyData, isLoading } = useHistory({
|
||||
const {
|
||||
data: historyData,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useHistory({
|
||||
limit,
|
||||
offset: page * limit,
|
||||
});
|
||||
@@ -50,13 +71,63 @@ export function HistoryTable() {
|
||||
const exportGeneration = useExportGeneration();
|
||||
const exportGenerationAudio = useExportGenerationAudio();
|
||||
const importGeneration = useImportGeneration();
|
||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
||||
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
|
||||
// Update accumulated history when new data arrives
|
||||
useEffect(() => {
|
||||
if (historyData?.items) {
|
||||
setTotal(historyData.total);
|
||||
if (page === 0) {
|
||||
// Reset to first page
|
||||
setAllHistory(historyData.items);
|
||||
} else {
|
||||
// Append new items, avoiding duplicates
|
||||
setAllHistory((prev) => {
|
||||
const existingIds = new Set(prev.map((item) => item.id));
|
||||
const newItems = historyData.items.filter((item) => !existingIds.has(item.id));
|
||||
return [...prev, ...newItems];
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [historyData, page]);
|
||||
|
||||
// Reset to page 0 when deletions or imports occur
|
||||
useEffect(() => {
|
||||
if (deleteGeneration.isSuccess || importGeneration.isSuccess) {
|
||||
setPage(0);
|
||||
setAllHistory([]);
|
||||
}
|
||||
}, [deleteGeneration.isSuccess, importGeneration.isSuccess]);
|
||||
|
||||
// Intersection Observer for infinite scroll
|
||||
useEffect(() => {
|
||||
const loadMoreEl = loadMoreRef.current;
|
||||
if (!loadMoreEl) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const target = entries[0];
|
||||
if (target.isIntersecting && !isFetching && allHistory.length < total) {
|
||||
setPage((prev) => prev + 1);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollRef.current,
|
||||
rootMargin: '100px',
|
||||
threshold: 0.1,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(loadMoreEl);
|
||||
return () => observer.disconnect();
|
||||
}, [isFetching, allHistory.length, total]);
|
||||
|
||||
// Track scroll position for gradient effect
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return;
|
||||
@@ -69,14 +140,14 @@ export function HistoryTable() {
|
||||
return () => scrollEl.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const handlePlay = (audioId: string, text: string) => {
|
||||
const handlePlay = (audioId: string, text: string, profileId: string) => {
|
||||
// If clicking the same audio, restart it from the beginning
|
||||
if (currentAudioId === audioId) {
|
||||
restartCurrentAudio();
|
||||
} else {
|
||||
// Otherwise, load the new audio
|
||||
// Otherwise, load the new audio and auto-play it
|
||||
const audioUrl = apiClient.getAudioUrl(audioId);
|
||||
setAudio(audioUrl, audioId, text.substring(0, 50));
|
||||
setAudioWithAutoPlay(audioUrl, audioId, profileId, text.substring(0, 50));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,7 +156,11 @@ export function HistoryTable() {
|
||||
{ generationId, text },
|
||||
{
|
||||
onError: (error) => {
|
||||
alert(`Failed to download audio: ${error.message}`);
|
||||
toast({
|
||||
title: 'Failed to download audio',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -96,26 +171,26 @@ export function HistoryTable() {
|
||||
{ generationId, text },
|
||||
{
|
||||
onError: (error) => {
|
||||
alert(`Failed to export generation: ${error.message}`);
|
||||
toast({
|
||||
title: 'Failed to export generation',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const _handleImportClick = () => {
|
||||
file_handleImportClickk.click();
|
||||
const handleDeleteClick = (generationId: string, profileName: string) => {
|
||||
setGenerationToDelete({ id: generationId, name: profileName });
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const _handleFileChange = (_e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
cons_handleFileChangeet.files?.[0];
|
||||
if (file) {
|
||||
// Validate file extension
|
||||
if (!file.name.endsWith('.voicebox.zip')) {
|
||||
alert('Please select a valid .voicebox.zip file');
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setImportDialogOpen(true);
|
||||
const handleDeleteConfirm = () => {
|
||||
if (generationToDelete) {
|
||||
deleteGeneration.mutate(generationToDelete.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setGenerationToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,42 +203,35 @@ export function HistoryTable() {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
alert(data.message || 'Generation imported successfully');
|
||||
toast({
|
||||
title: 'Generation imported',
|
||||
description: data.message || 'Generation imported successfully',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(`Failed to import generation: ${error.message}`);
|
||||
toast({
|
||||
title: 'Failed to import generation',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
if (isLoading && page === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const history = historyData?.items || [];
|
||||
const total = historyData?.total || 0;
|
||||
const hasMore = history.length === limit && (page + 1) * limit < total;
|
||||
const history = allHistory;
|
||||
const hasMore = allHistory.length < total;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 relative">
|
||||
{/* <div className="flex justify-between items-center mb-4 shrink-0">
|
||||
<h2 className="text-2xl font-bold">History</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleImportClick}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import Generation
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".voicebox.zip"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{history.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed mb-5 border-muted rounded-md text-muted-foreground flex-1 flex items-center justify-center">
|
||||
No voice generations, yet...
|
||||
@@ -176,8 +244,8 @@ export function HistoryTable() {
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'flex-1 min-h-0 overflow-y-auto space-y-2',
|
||||
isPlayerVisible && 'max-h-[calc(100vh-117px)]',
|
||||
'flex-1 min-h-0 overflow-y-auto space-y-2 pb-4',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
{history.map((gen) => {
|
||||
@@ -185,17 +253,32 @@ export function HistoryTable() {
|
||||
return (
|
||||
<div
|
||||
key={gen.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
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',
|
||||
isCurrentlyPlaying && 'bg-muted/70',
|
||||
)}
|
||||
aria-label={
|
||||
isCurrentlyPlaying
|
||||
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
|
||||
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Press Enter to play.`
|
||||
}
|
||||
onMouseDown={(e) => {
|
||||
// Don't trigger play if clicking on textarea or if text is selected
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
||||
return;
|
||||
}
|
||||
handlePlay(gen.id, gen.text);
|
||||
handlePlay(gen.id, gen.text, gen.profile_id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
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 */}
|
||||
@@ -224,11 +307,17 @@ export function HistoryTable() {
|
||||
<Textarea
|
||||
value={gen.text}
|
||||
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
||||
readOnly
|
||||
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Far right - Ellipsis actions */}
|
||||
<div className="w-10 shrink-0 flex justify-end">
|
||||
<div
|
||||
className="w-10 shrink-0 flex justify-end"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -236,13 +325,14 @@ export function HistoryTable() {
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label="Actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handlePlay(gen.id, gen.text)}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
</DropdownMenuItem>
|
||||
@@ -261,7 +351,7 @@ export function HistoryTable() {
|
||||
Export Package
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => deleteGeneration.mutate(gen.id)}
|
||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||
disabled={deleteGeneration.isPending}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
@@ -274,28 +364,53 @@ export function HistoryTable() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(total > limit || page > 0) && (
|
||||
<div className="flex justify-between items-center mt-4 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {page + 1} • {total} total
|
||||
{/* Load more trigger element */}
|
||||
{hasMore && (
|
||||
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
|
||||
{isFetching && <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* End of list indicator */}
|
||||
{!hasMore && history.length > 0 && (
|
||||
<div className="text-center py-4 text-xs text-muted-foreground">
|
||||
You've reached the end
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Generation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this generation from "{generationToDelete?.name}"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setGenerationToDelete(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteGeneration.isPending}
|
||||
>
|
||||
{deleteGeneration.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Sparkles, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { useImportProfile } from '@/lib/hooks/useProfiles';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
export function MainEditor() {
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const importProfile = useImportProfile();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (!file.name.endsWith('.voicebox.zip')) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please select a valid .voicebox.zip file',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setImportDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportConfirm = () => {
|
||||
if (selectedFile) {
|
||||
importProfile.mutate(selectedFile, {
|
||||
onSuccess: () => {
|
||||
setImportDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
toast({
|
||||
title: 'Profile imported',
|
||||
description: 'Voice profile imported successfully',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to import profile',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// 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">
|
||||
{/* Left Column */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden relative">
|
||||
{/* Scroll Mask - Always visible, behind content */}
|
||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
|
||||
|
||||
{/* Fixed Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10">
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h2 className="text-2xl font-bold">Voicebox</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleImportClick}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import Voice
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".voicebox.zip"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Create Voice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'flex-1 min-h-0 overflow-y-auto pt-14',
|
||||
isPlayerVisible ? BOTTOM_SAFE_AREA_PADDING : 'pb-4',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="shrink-0 flex flex-col">
|
||||
<ProfileList />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - History */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
<HistoryTable />
|
||||
</div>
|
||||
|
||||
{/* Floating Generate Box */}
|
||||
<FloatingGenerateBox isPlayerOpen={!!audioUrl} />
|
||||
|
||||
{/* Import Dialog */}
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Import the profile from "{selectedFile?.name}". This will create a new profile with
|
||||
all samples.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setImportDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportConfirm}
|
||||
disabled={importProfile.isPending || !selectedFile}
|
||||
>
|
||||
{importProfile.isPending ? 'Importing...' : 'Import'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
||||
|
||||
export function ModelsTab() {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<ModelManagement />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# Server settings and connection components
|
||||
@@ -1,9 +1,12 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Loader2, XCircle } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -14,10 +17,10 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { setKeepServerRunning } from '@/lib/tauri';
|
||||
|
||||
const connectionSchema = z.object({
|
||||
serverUrl: z.string().url('Please enter a valid URL'),
|
||||
@@ -26,11 +29,15 @@ const connectionSchema = z.object({
|
||||
type ConnectionFormValues = z.infer<typeof connectionSchema>;
|
||||
|
||||
export function ConnectionForm() {
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
||||
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
||||
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
|
||||
const mode = useServerStore((state) => state.mode);
|
||||
const setMode = useServerStore((state) => state.setMode);
|
||||
const { toast } = useToast();
|
||||
const { data: health, isLoading, error: healthError } = useServerHealth();
|
||||
|
||||
const form = useForm<ConnectionFormValues>({
|
||||
resolver: zodResolver(connectionSchema),
|
||||
@@ -48,7 +55,7 @@ export function ConnectionForm() {
|
||||
|
||||
function onSubmit(data: ConnectionFormValues) {
|
||||
setServerUrl(data.serverUrl);
|
||||
form.reset(data); // Reset form state after successful submission
|
||||
form.reset(data);
|
||||
toast({
|
||||
title: 'Server URL updated',
|
||||
description: `Connected to ${data.serverUrl}`,
|
||||
@@ -56,7 +63,7 @@ export function ConnectionForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card role="region" aria-label="Server Connection" tabIndex={0}>
|
||||
<CardHeader>
|
||||
<CardTitle>Server Connection</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -82,6 +89,37 @@ export function ConnectionForm() {
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">Checking connection...</span>
|
||||
</div>
|
||||
) : healthError ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
<span className="text-sm text-destructive">
|
||||
Connection failed: {healthError.message}
|
||||
</span>
|
||||
</div>
|
||||
) : health ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
variant={health.model_loaded || health.model_downloaded ? 'default' : 'secondary'}
|
||||
>
|
||||
{health.model_loaded || health.model_downloaded ? 'Model Ready' : 'No Model'}
|
||||
</Badge>
|
||||
<Badge variant={health.gpu_available ? 'default' : 'secondary'}>
|
||||
GPU: {health.gpu_available ? 'Available' : 'Not Available'}
|
||||
</Badge>
|
||||
{health.vram_used_mb && (
|
||||
<Badge variant="outline">VRAM: {health.vram_used_mb.toFixed(0)} MB</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
@@ -89,7 +127,7 @@ export function ConnectionForm() {
|
||||
checked={keepServerRunningOnClose}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setKeepServerRunningOnClose(checked);
|
||||
setKeepServerRunning(checked).catch((error) => {
|
||||
platform.lifecycle.setKeepServerRunning(checked).catch((error) => {
|
||||
console.error('Failed to sync setting to Rust:', error);
|
||||
});
|
||||
toast({
|
||||
@@ -114,6 +152,38 @@ export function ConnectionForm() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{platform.metadata.isTauri && (
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="allowNetworkAccess"
|
||||
checked={mode === 'remote'}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setMode(checked ? 'remote' : 'local');
|
||||
toast({
|
||||
title: 'Setting updated',
|
||||
description: checked
|
||||
? 'Network access enabled. Restart the app to apply.'
|
||||
: 'Network access disabled. Restart the app to apply.',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="allowNetworkAccess"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
Allow network access
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Makes the server accessible from other devices on your network. Restart the app
|
||||
after changing this setting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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);
|
||||
|
||||
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={2000}
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
</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
@@ -1,22 +1,30 @@
|
||||
import { Loader2, XCircle } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import type { ModelProgress as ModelProgressType } from '@/lib/api/types';
|
||||
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
interface ModelProgressProps {
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
/** Only connect to SSE when actively downloading - prevents connection exhaustion */
|
||||
isDownloading?: boolean;
|
||||
}
|
||||
|
||||
export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
export function ModelProgress({ modelName, displayName, isDownloading = false }: ModelProgressProps) {
|
||||
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverUrl || isSubscribed) return;
|
||||
// IMPORTANT: Only connect to SSE when this specific model is downloading
|
||||
// Opening SSE connections for all models exhausts HTTP/1.1 connection limits (6 per origin)
|
||||
// which causes other fetches (like the download trigger) to be queued/blocked
|
||||
if (!serverUrl || !isDownloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[ModelProgress] Connecting SSE for ${modelName}`);
|
||||
|
||||
// Subscribe to progress updates via Server-Sent Events
|
||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||
@@ -28,8 +36,8 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
|
||||
// Close connection if complete or error
|
||||
if (data.status === 'complete' || data.status === 'error') {
|
||||
console.log(`[ModelProgress] Download ${data.status} for ${modelName}, closing SSE`);
|
||||
eventSource.close();
|
||||
setIsSubscribed(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing progress event:', error);
|
||||
@@ -37,18 +45,15 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
console.error(`[ModelProgress] SSE error for ${modelName}:`, error);
|
||||
eventSource.close();
|
||||
setIsSubscribed(false);
|
||||
};
|
||||
|
||||
setIsSubscribed(true);
|
||||
|
||||
return () => {
|
||||
console.log(`[ModelProgress] Cleanup - closing SSE for ${modelName}`);
|
||||
eventSource.close();
|
||||
setIsSubscribed(false);
|
||||
};
|
||||
}, [serverUrl, modelName, isSubscribed]);
|
||||
}, [serverUrl, modelName, isDownloading]);
|
||||
|
||||
// Don't render if no progress or if complete/error and some time has passed
|
||||
if (
|
||||
@@ -63,13 +68,11 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (progress.status) {
|
||||
case 'complete':
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case 'error':
|
||||
return <XCircle className="h-4 w-4 text-destructive" />;
|
||||
case 'downloading':
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||
import { Loader2, XCircle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { ModelProgress } from './ModelProgress';
|
||||
|
||||
export function ServerStatus() {
|
||||
const { data: health, isLoading, error } = useServerHealth();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card role="region" aria-label="Server Status" tabIndex={0}>
|
||||
<CardHeader>
|
||||
<CardTitle>Server Status</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -20,16 +19,6 @@ export function ServerStatus() {
|
||||
<div className="font-mono text-sm">{serverUrl}</div>
|
||||
</div>
|
||||
|
||||
{/* Model download progress */}
|
||||
<div className="space-y-2">
|
||||
<ModelProgress modelName="qwen-tts-1.7B" displayName="Qwen TTS 1.7B" />
|
||||
<ModelProgress modelName="qwen-tts-0.6B" displayName="Qwen TTS 0.6B" />
|
||||
<ModelProgress modelName="whisper-base" displayName="Whisper Base" />
|
||||
<ModelProgress modelName="whisper-small" displayName="Whisper Small" />
|
||||
<ModelProgress modelName="whisper-medium" displayName="Whisper Medium" />
|
||||
<ModelProgress modelName="whisper-large" displayName="Whisper Large" />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -43,7 +32,6 @@ export function ServerStatus() {
|
||||
) : health ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm">Connected</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { RefreshCw, Download, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { AlertCircle, Download, RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function UpdateStatus() {
|
||||
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(false);
|
||||
const platform = usePlatform();
|
||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
getVersion()
|
||||
platform.metadata
|
||||
.getVersion()
|
||||
.then(setCurrentVersion)
|
||||
.catch(() => setCurrentVersion('0.1.0'));
|
||||
}, []);
|
||||
.catch(() => setCurrentVersion('Unknown'));
|
||||
}, [platform]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card
|
||||
role="region"
|
||||
aria-label="App Updates"
|
||||
tabIndex={0}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>App Updates</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -30,7 +36,7 @@ export function UpdateStatus() {
|
||||
</div>
|
||||
<Button
|
||||
onClick={checkForUpdates}
|
||||
disabled={status.checking || status.downloading || status.installing}
|
||||
disabled={status.checking || status.downloading || status.readyToInstall}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
@@ -53,7 +59,7 @@ export function UpdateStatus() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.available && !status.downloading && !status.installing && (
|
||||
{status.available && !status.downloading && !status.readyToInstall && (
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -64,34 +70,57 @@ export function UpdateStatus() {
|
||||
</div>
|
||||
<Button onClick={downloadAndInstall} className="w-full" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Install Update
|
||||
Download Update
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.downloading && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Download className="h-4 w-4" />
|
||||
Downloading update...
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Downloading update...
|
||||
</div>
|
||||
{status.downloadProgress !== undefined && (
|
||||
<span className="text-muted-foreground">{status.downloadProgress}%</span>
|
||||
)}
|
||||
</div>
|
||||
<Progress />
|
||||
<Progress value={status.downloadProgress} />
|
||||
{status.downloadedBytes !== undefined &&
|
||||
status.totalBytes !== undefined &&
|
||||
status.totalBytes > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
|
||||
{(status.totalBytes / 1024 / 1024).toFixed(1)} MB
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.installing && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
Installing update...
|
||||
{status.readyToInstall && (
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-accent/30 border-accent/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<div className="font-semibold">Update Ready to Install</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Version {status.version} has been downloaded
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">App will restart automatically</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
The app needs to restart to complete the installation. You can do this now or later at
|
||||
your convenience.
|
||||
</div>
|
||||
<Button onClick={restartAndInstall} className="w-full" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Restart Now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status.available && !status.checking && !status.error && status.checking === false && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
You're up to date
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||
import { GenerationSettings } from '@/components/ServerSettings/GenerationSettings';
|
||||
import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration';
|
||||
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
export function ServerTab() {
|
||||
const platform = usePlatform();
|
||||
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
|
||||
return (
|
||||
<div
|
||||
className={cn('overflow-y-auto flex flex-col', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ConnectionForm />
|
||||
<GenerationSettings />
|
||||
{platform.metadata.isTauri && <GpuAcceleration />}
|
||||
{platform.metadata.isTauri && <UpdateStatus />}
|
||||
</div>
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
Created by{' '}
|
||||
<a
|
||||
href="https://github.com/jamiepine"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Jamie Pine
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
import { Loader2, Settings, Volume2 } from 'lucide-react';
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||
import { Box, BookOpen, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
isMacOS?: boolean;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'main', icon: Volume2, label: 'Main' },
|
||||
{ id: 'settings', icon: Settings, label: 'Settings' },
|
||||
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
|
||||
{ id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' },
|
||||
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
|
||||
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
|
||||
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
|
||||
{ id: 'server', path: '/server', icon: Server, label: 'Server' },
|
||||
];
|
||||
|
||||
export function Sidebar({ activeTab, onTabChange, 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();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -36,13 +40,16 @@ export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) {
|
||||
<div className="flex flex-col gap-3">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
// For index route, use exact match; for others, use default matching
|
||||
const isActive =
|
||||
tab.path === '/'
|
||||
? matchRoute({ to: '/', exact: true })
|
||||
: matchRoute({ to: tab.path });
|
||||
|
||||
return (
|
||||
<button
|
||||
<Link
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
to={tab.path}
|
||||
className={cn(
|
||||
'w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200',
|
||||
'hover:bg-muted/50',
|
||||
@@ -52,7 +59,7 @@ export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) {
|
||||
aria-label={tab.label}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { StoryContent } from './StoryContent';
|
||||
import { StoryList } from './StoryList';
|
||||
|
||||
export function StoriesTab() {
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden relative">
|
||||
{/* Left Column - Story List */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden w-full max-w-[360px] shrink-0">
|
||||
<StoryList />
|
||||
</div>
|
||||
|
||||
{/* Right Column - Story Content */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden flex-1">
|
||||
<StoryContent />
|
||||
</div>
|
||||
|
||||
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
|
||||
<FloatingGenerateBox showVoiceSelector isPlayerOpen={!!audioUrl} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { StoryItemDetail } from '@/lib/api/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
interface StoryChatItemProps {
|
||||
item: StoryItemDetail;
|
||||
storyId: string;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
currentTimeMs: number;
|
||||
isPlaying: boolean;
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
export function StoryChatItem({
|
||||
item,
|
||||
onRemove,
|
||||
currentTimeMs,
|
||||
isPlaying,
|
||||
dragHandleProps,
|
||||
isDragging,
|
||||
}: StoryChatItemProps) {
|
||||
const seek = useStoryStore((state) => state.seek);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
|
||||
const avatarUrl = `${serverUrl}/profiles/${item.profile_id}/avatar`;
|
||||
|
||||
// Check if this item is currently playing based on timecode
|
||||
const itemStartMs = item.start_time_ms;
|
||||
const itemEndMs = item.start_time_ms + item.duration * 1000;
|
||||
const isCurrentlyPlaying = isPlaying && currentTimeMs >= itemStartMs && currentTimeMs < itemEndMs;
|
||||
|
||||
const handlePlay = () => {
|
||||
// Seek to the start of this item
|
||||
seek(itemStartMs);
|
||||
};
|
||||
|
||||
const formatTime = (ms: number): string => {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const milliseconds = Math.floor((ms % 1000) / 100);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-4 rounded-lg border transition-colors',
|
||||
isCurrentlyPlaying && 'bg-muted/70 border-primary',
|
||||
!isCurrentlyPlaying && 'hover:bg-muted/50',
|
||||
isDragging && 'opacity-50 shadow-lg',
|
||||
)}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
{dragHandleProps && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 cursor-grab active:cursor-grabbing touch-none text-muted-foreground hover:text-foreground transition-colors"
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Voice Avatar */}
|
||||
<div className="shrink-0">
|
||||
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
|
||||
{!avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${item.profile_name} avatar`}
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-all duration-200',
|
||||
!isCurrentlyPlaying && 'grayscale'
|
||||
)}
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Mic className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-sm">{item.profile_name}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.language}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums ml-auto">
|
||||
{formatTime(itemStartMs)}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={item.text}
|
||||
className="flex-1 resize-none text-sm text-muted-foreground select-text bg-card cursor-text"
|
||||
readOnly
|
||||
onDoubleClick={handlePlay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handlePlay}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Play from here
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onRemove} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove from Story
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sortable wrapper component
|
||||
export function SortableStoryChatItem(props: Omit<StoryChatItemProps, 'dragHandleProps' | 'isDragging'>) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: props.item.generation_id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<StoryChatItem
|
||||
{...props}
|
||||
dragHandleProps={listeners}
|
||||
isDragging={isDragging}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Download, Plus } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useHistory } from '@/lib/hooks/useHistory';
|
||||
import {
|
||||
useAddStoryItem,
|
||||
useExportStoryAudio,
|
||||
useRemoveStoryItem,
|
||||
useReorderStoryItems,
|
||||
useStory,
|
||||
} from '@/lib/hooks/useStories';
|
||||
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import { SortableStoryChatItem } from './StoryChatItem';
|
||||
|
||||
export function StoryContent() {
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const { data: story, isLoading } = useStory(selectedStoryId);
|
||||
const removeItem = useRemoveStoryItem();
|
||||
const reorderItems = useReorderStoryItems();
|
||||
const exportAudio = useExportStoryAudio();
|
||||
const addStoryItem = useAddStoryItem();
|
||||
const { toast } = useToast();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Add generation popover state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const { data: historyData } = useHistory();
|
||||
|
||||
// Filter generations not in story and matching search
|
||||
const availableGenerations = useMemo(() => {
|
||||
if (!historyData?.items || !story) return [];
|
||||
const storyGenerationIds = new Set(story.items.map((i) => i.generation_id));
|
||||
const query = searchQuery.toLowerCase();
|
||||
return historyData.items.filter(
|
||||
(gen) =>
|
||||
!storyGenerationIds.has(gen.id) &&
|
||||
(gen.text.toLowerCase().includes(query) ||
|
||||
gen.profile_name.toLowerCase().includes(query)),
|
||||
);
|
||||
}, [historyData, story, searchQuery]);
|
||||
|
||||
// Get track editor height from store for dynamic padding
|
||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||
|
||||
// Track editor is shown when story has items
|
||||
const hasBottomBar = story && story.items.length > 0;
|
||||
|
||||
// Calculate dynamic bottom padding: track editor + gap
|
||||
const bottomPadding = hasBottomBar ? trackEditorHeight + 24 : 0;
|
||||
|
||||
// Drag and drop sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
// Playback state (for auto-scroll and item highlighting)
|
||||
const isPlaying = useStoryStore((state) => state.isPlaying);
|
||||
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
||||
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
|
||||
|
||||
// Refs for auto-scrolling to playing item
|
||||
const itemRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const lastScrolledItemRef = useRef<string | null>(null);
|
||||
|
||||
// Use playback hook
|
||||
useStoryPlayback(story?.items);
|
||||
|
||||
// Sort items by start_time_ms
|
||||
const sortedItems = useMemo(() => {
|
||||
if (!story?.items) return [];
|
||||
return [...story.items].sort((a, b) => a.start_time_ms - b.start_time_ms);
|
||||
}, [story?.items]);
|
||||
|
||||
// Find the currently playing item based on timecode
|
||||
const currentlyPlayingItemId = useMemo(() => {
|
||||
if (!isPlaying || playbackStoryId !== story?.id || !sortedItems.length) {
|
||||
return null;
|
||||
}
|
||||
const playingItem = sortedItems.find((item) => {
|
||||
const itemStart = item.start_time_ms;
|
||||
const itemEnd = item.start_time_ms + item.duration * 1000;
|
||||
return currentTimeMs >= itemStart && currentTimeMs < itemEnd;
|
||||
});
|
||||
return playingItem?.generation_id ?? null;
|
||||
}, [isPlaying, playbackStoryId, story?.id, sortedItems, currentTimeMs]);
|
||||
|
||||
// Auto-scroll to the currently playing item
|
||||
useEffect(() => {
|
||||
if (!currentlyPlayingItemId || currentlyPlayingItemId === lastScrolledItemRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = itemRefsMap.current.get(currentlyPlayingItemId);
|
||||
if (element && scrollRef.current) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
lastScrolledItemRef.current = currentlyPlayingItemId;
|
||||
}
|
||||
}, [currentlyPlayingItemId]);
|
||||
|
||||
// Reset last scrolled item when playback stops
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
lastScrolledItemRef.current = null;
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
const handleRemoveItem = (itemId: string) => {
|
||||
if (!story) return;
|
||||
|
||||
removeItem.mutate(
|
||||
{
|
||||
storyId: story.id,
|
||||
itemId,
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to remove item',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!story || !over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = sortedItems.findIndex((item) => item.generation_id === active.id);
|
||||
const newIndex = sortedItems.findIndex((item) => item.generation_id === over.id);
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
// Calculate the new order
|
||||
const newOrder = arrayMove(sortedItems, oldIndex, newIndex);
|
||||
const generationIds = newOrder.map((item) => item.generation_id);
|
||||
|
||||
// Send reorder request to backend
|
||||
reorderItems.mutate(
|
||||
{
|
||||
storyId: story.id,
|
||||
data: { generation_ids: generationIds },
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to reorder items',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleExportAudio = () => {
|
||||
if (!story) return;
|
||||
|
||||
exportAudio.mutate(
|
||||
{
|
||||
storyId: story.id,
|
||||
storyName: story.name,
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to export audio',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddGeneration = (generationId: string) => {
|
||||
if (!story) return;
|
||||
|
||||
addStoryItem.mutate(
|
||||
{
|
||||
storyId: story.id,
|
||||
data: { generation_id: generationId },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddOpen(false);
|
||||
setSearchQuery('');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to add generation',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (!selectedStoryId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium mb-2">Select a story</p>
|
||||
<p className="text-sm">Choose a story from the list to view its content</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">Loading story...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!story) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium mb-2">Story not found</p>
|
||||
<p className="text-sm">The selected story could not be loaded</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{story.name}</h2>
|
||||
{story.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="p-2 border-b">
|
||||
<Input
|
||||
placeholder="Search by name or transcript..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{availableGenerations.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{searchQuery
|
||||
? 'No matching generations found'
|
||||
: 'No available generations'}
|
||||
</div>
|
||||
) : (
|
||||
availableGenerations.map((gen) => (
|
||||
<button
|
||||
key={gen.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 hover:bg-muted transition-colors border-b last:border-b-0"
|
||||
onClick={() => handleAddGeneration(gen.id)}
|
||||
>
|
||||
<div className="font-medium text-sm">{gen.profile_name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{gen.text.length > 50 ? `${gen.text.substring(0, 50)}...` : gen.text}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{story.items.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExportAudio}
|
||||
disabled={exportAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Audio
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-3"
|
||||
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
|
||||
>
|
||||
{sortedItems.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
||||
<p className="text-sm">No items in this story</p>
|
||||
<p className="text-xs mt-2">Generate speech using the box below to add items</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortedItems.map((item) => item.generation_id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{sortedItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
itemRefsMap.current.set(item.generation_id, el);
|
||||
} else {
|
||||
itemRefsMap.current.delete(item.generation_id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SortableStoryChatItem
|
||||
item={item}
|
||||
storyId={story.id}
|
||||
index={index}
|
||||
onRemove={() => handleRemoveItem(item.id)}
|
||||
currentTimeMs={currentTimeMs}
|
||||
isPlaying={isPlaying && playbackStoryId === story.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import { Plus, BookOpen, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useStories, useCreateStory, useUpdateStory, useDeleteStory } from '@/lib/hooks/useStories';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatDate } from '@/lib/utils/format';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
|
||||
export function StoryList() {
|
||||
const { data: stories, isLoading } = useStories();
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||
const createStory = useCreateStory();
|
||||
const updateStory = useUpdateStory();
|
||||
const deleteStory = useDeleteStory();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [editingStory, setEditingStory] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
} | null>(null);
|
||||
const [deletingStoryId, setDeletingStoryId] = useState<string | null>(null);
|
||||
const [newStoryName, setNewStoryName] = useState('');
|
||||
const [newStoryDescription, setNewStoryDescription] = useState('');
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleCreateStory = () => {
|
||||
if (!newStoryName.trim()) {
|
||||
toast({
|
||||
title: 'Name required',
|
||||
description: 'Please enter a story name',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createStory.mutate(
|
||||
{
|
||||
name: newStoryName.trim(),
|
||||
description: newStoryDescription.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (story) => {
|
||||
setSelectedStoryId(story.id);
|
||||
setCreateDialogOpen(false);
|
||||
setNewStoryName('');
|
||||
setNewStoryDescription('');
|
||||
toast({
|
||||
title: 'Story created',
|
||||
description: `"${story.name}" has been created`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to create story',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleEditClick = (story: { id: string; name: string; description?: string }) => {
|
||||
setEditingStory(story);
|
||||
setNewStoryName(story.name);
|
||||
setNewStoryDescription(story.description || '');
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleUpdateStory = () => {
|
||||
if (!editingStory || !newStoryName.trim()) {
|
||||
toast({
|
||||
title: 'Name required',
|
||||
description: 'Please enter a story name',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateStory.mutate(
|
||||
{
|
||||
storyId: editingStory.id,
|
||||
data: {
|
||||
name: newStoryName.trim(),
|
||||
description: newStoryDescription.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditDialogOpen(false);
|
||||
setEditingStory(null);
|
||||
setNewStoryName('');
|
||||
setNewStoryDescription('');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to update story',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (storyId: string) => {
|
||||
setDeletingStoryId(storyId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deletingStoryId) return;
|
||||
|
||||
deleteStory.mutate(deletingStoryId, {
|
||||
onSuccess: () => {
|
||||
// Clear selection if deleting the currently selected story
|
||||
if (selectedStoryId === deletingStoryId) {
|
||||
setSelectedStoryId(null);
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
setDeletingStoryId(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to delete story',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">Loading stories...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const storyList = stories || [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h2 className="text-2xl font-bold">Stories</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Story
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Story List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
||||
{storyList.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">No stories yet</p>
|
||||
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
storyList.map((story) => (
|
||||
<div
|
||||
key={story.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'h-24 p-4 border rounded-2xl transition-colors group flex items-center cursor-pointer',
|
||||
selectedStoryId === story.id && 'bg-muted border-primary',
|
||||
)}
|
||||
aria-label={
|
||||
selectedStoryId === story.id
|
||||
? `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Selected. Press Enter to select.`
|
||||
: `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Press Enter to select.`
|
||||
}
|
||||
aria-pressed={selectedStoryId === story.id}
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setSelectedStoryId(story.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
||||
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||
<h3 className="font-medium truncate">{story.name}</h3>
|
||||
{story.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 truncate">
|
||||
{story.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(story.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Actions for ${story.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(story.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Story Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Story</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new story to organize your voice generations into conversations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="story-name">Name</Label>
|
||||
<Input
|
||||
id="story-name"
|
||||
placeholder="My Story"
|
||||
value={newStoryName}
|
||||
onChange={(e) => setNewStoryName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreateStory();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="story-description">Description (optional)</Label>
|
||||
<Textarea
|
||||
id="story-description"
|
||||
placeholder="A conversation between..."
|
||||
value={newStoryDescription}
|
||||
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateStory} disabled={createStory.isPending}>
|
||||
{createStory.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Story Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Story</DialogTitle>
|
||||
<DialogDescription>Update the story name and description.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-story-name">Name</Label>
|
||||
<Input
|
||||
id="edit-story-name"
|
||||
placeholder="My Story"
|
||||
value={newStoryName}
|
||||
onChange={(e) => setNewStoryName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleUpdateStory();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-story-description">Description (optional)</Label>
|
||||
<Textarea
|
||||
id="edit-story-description"
|
||||
placeholder="A conversation between..."
|
||||
value={newStoryDescription}
|
||||
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateStory} disabled={updateStory.isPending}>
|
||||
{updateStory.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Story Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the story and all its items. This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteStory.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteStory.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
# Voice profile management components
|
||||
@@ -1,8 +1,31 @@
|
||||
import { Mic, Square, Play, Pause } from 'lucide-react';
|
||||
import { Mic, Pause, Play, Square } from 'lucide-react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { Visualizer } from 'react-sound-visualizer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
|
||||
const MemoizedWaveform = memo(function MemoizedWaveform({
|
||||
audioStream,
|
||||
}: {
|
||||
audioStream: MediaStream;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none flex items-center justify-center opacity-30">
|
||||
<Visualizer audio={audioStream} autoStart strokeColor="#b39a3d">
|
||||
{({ canvasRef }) => (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={500}
|
||||
height={150}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
)}
|
||||
</Visualizer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface AudioSampleRecordingProps {
|
||||
file: File | null | undefined;
|
||||
isRecording: boolean;
|
||||
@@ -14,6 +37,7 @@ interface AudioSampleRecordingProps {
|
||||
onPlayPause: () => void;
|
||||
isPlaying: boolean;
|
||||
isTranscribing?: boolean;
|
||||
showWaveform?: boolean;
|
||||
}
|
||||
|
||||
export function AudioSampleRecording({
|
||||
@@ -27,34 +51,68 @@ export function AudioSampleRecording({
|
||||
onPlayPause,
|
||||
isPlaying,
|
||||
isTranscribing = false,
|
||||
showWaveform = true,
|
||||
}: AudioSampleRecordingProps) {
|
||||
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
|
||||
|
||||
// Request microphone access when component mounts
|
||||
useEffect(() => {
|
||||
if (!showWaveform) return;
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
|
||||
|
||||
let stream: MediaStream | null = null;
|
||||
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ audio: true, video: false })
|
||||
.then((s) => {
|
||||
stream = s;
|
||||
setAudioStream(s);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Could not access microphone for visualization:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [showWaveform]);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Record Audio</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{!isRecording && !file && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||
<div className="relative flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px] overflow-hidden">
|
||||
{showWaveform && audioStream && (
|
||||
<MemoizedWaveform audioStream={audioStream} />
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStart}
|
||||
size="lg"
|
||||
className="flex items-center gap-2"
|
||||
className="relative z-10 flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-5 w-5" />
|
||||
Start Recording
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||
Click to start recording. Maximum duration: 30 seconds.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex flex-col items-center justify-center gap-4 p-4 border-2 border-accent rounded-lg bg-accent/5 min-h-[180px] overflow-hidden">
|
||||
{showWaveform && audioStream && (
|
||||
<MemoizedWaveform audioStream={audioStream} />
|
||||
)}
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
||||
<div className="h-3 w-3 rounded-full bg-accent animate-pulse" />
|
||||
<span className="text-lg font-mono font-semibold">
|
||||
{formatAudioDuration(duration)}
|
||||
</span>
|
||||
@@ -63,13 +121,12 @@ export function AudioSampleRecording({
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
variant="destructive"
|
||||
className="flex items-center gap-2"
|
||||
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop Recording
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||
{formatAudioDuration(30 - duration)} remaining
|
||||
</p>
|
||||
</div>
|
||||
@@ -81,15 +138,14 @@ export function AudioSampleRecording({
|
||||
<Mic className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Recording complete</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
File: {file.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Monitor, Square, Play, Pause, Mic } from 'lucide-react';
|
||||
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
|
||||
interface AudioSampleSystemProps {
|
||||
@@ -30,17 +30,11 @@ export function AudioSampleSystem({
|
||||
}: AudioSampleSystemProps) {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Capture System Audio</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{!isRecording && !file && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStart}
|
||||
size="lg"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
Start Capture
|
||||
</Button>
|
||||
@@ -81,15 +75,14 @@ export function AudioSampleSystem({
|
||||
<Monitor className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Capture complete</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
File: {file.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Mic, Pause, Play, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
|
||||
interface AudioSampleUploadProps {
|
||||
file: File | null | undefined;
|
||||
@@ -31,7 +31,6 @@ export function AudioSampleUpload({
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Audio File</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
@@ -111,6 +110,7 @@ export function AudioSampleUpload({
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
disabled={isValidating}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Download, Edit, Eye, Mic, Trash2 } from 'lucide-react';
|
||||
import { Download, Edit, Mic, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -15,25 +15,28 @@ import {
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { ProfileDetail } from './ProfileDetail';
|
||||
|
||||
interface ProfileCardProps {
|
||||
profile: VoiceProfileResponse;
|
||||
}
|
||||
|
||||
export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
const deleteProfile = useDeleteProfile();
|
||||
const exportProfile = useExportProfile();
|
||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
const isSelected = selectedProfileId === profile.id;
|
||||
|
||||
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
|
||||
|
||||
const handleSelect = () => {
|
||||
setSelectedProfileId(isSelected ? null : profile.id);
|
||||
};
|
||||
@@ -58,6 +61,19 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
exportProfile.mutate(profile.id);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button')) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
};
|
||||
|
||||
const selectLabel = isSelected
|
||||
? `${profile.name}, ${profile.language}. Selected as voice for generation.`
|
||||
: `${profile.name}, ${profile.language}. Select as voice for generation.`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
@@ -66,11 +82,28 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
isSelected && 'ring-2 ring-primary shadow-md',
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={selectLabel}
|
||||
aria-pressed={isSelected}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<CardHeader className="p-3 pb-2">
|
||||
<CardTitle className="flex items-center gap-1.5 text-base font-medium">
|
||||
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0">
|
||||
<Mic className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{avatarUrl && !avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${profile.name} avatar`}
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-all duration-200',
|
||||
!isSelected && 'grayscale',
|
||||
)}
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Mic className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span className="break-words">{profile.name}</span>
|
||||
</CardTitle>
|
||||
@@ -85,14 +118,6 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
||||
<CircleButton
|
||||
icon={Eye}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
aria-label="View details"
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Download}
|
||||
onClick={handleExport}
|
||||
@@ -117,8 +142,6 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ProfileDetail profileId={profile.id} open={detailOpen} onOpenChange={setDetailOpen} />
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { formatDate } from '@/lib/utils/format';
|
||||
import { SampleList } from './SampleList';
|
||||
|
||||
interface ProfileDetailProps {
|
||||
profileId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ProfileDetail({ profileId, open, onOpenChange }: ProfileDetailProps) {
|
||||
const { data: profile, isLoading } = useProfile(profileId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<div className="text-muted-foreground">Loading profile...</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{profile.name}</DialogTitle>
|
||||
<DialogDescription>Manage samples and view profile details</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{profile.description && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-1">Description</h3>
|
||||
<p className="text-sm text-muted-foreground">{profile.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline">{profile.language}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Created {formatDate(profile.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<SampleList profileId={profileId} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Mic, Monitor, Upload } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Edit2, Mic, Monitor, Upload, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -36,49 +36,21 @@ import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||
import {
|
||||
useAddSample,
|
||||
useCreateProfile,
|
||||
useDeleteAvatar,
|
||||
useProfile,
|
||||
useUpdateProfile,
|
||||
useUploadAvatar,
|
||||
} from '@/lib/hooks/useProfiles';
|
||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { convertToWav, formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
|
||||
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||
|
||||
// Helper function to get audio duration from File
|
||||
async function getAudioDuration(file: File & { recordedDuration?: number }): Promise<number> {
|
||||
// If the file has a recordedDuration property (from our recording hooks),
|
||||
// use that instead of trying to read metadata. This fixes issues on Windows
|
||||
// where WebM files from MediaRecorder don't have proper duration metadata.
|
||||
if (file.recordedDuration !== undefined && Number.isFinite(file.recordedDuration)) {
|
||||
return file.recordedDuration;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
// Check if duration is valid (not Infinity or NaN)
|
||||
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
||||
resolve(audio.duration);
|
||||
} else {
|
||||
reject(new Error('Audio file has invalid duration metadata'));
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load audio file'));
|
||||
});
|
||||
|
||||
audio.src = url;
|
||||
});
|
||||
}
|
||||
import { SampleList } from './SampleList';
|
||||
|
||||
const MAX_AUDIO_DURATION_SECONDS = 30;
|
||||
|
||||
@@ -88,6 +60,7 @@ const baseProfileSchema = z.object({
|
||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||
sampleFile: z.instanceof(File).optional(),
|
||||
referenceText: z.string().max(1000).optional(),
|
||||
avatarFile: z.instanceof(File).optional(),
|
||||
});
|
||||
|
||||
const profileSchema = baseProfileSchema.refine(
|
||||
@@ -106,22 +79,52 @@ const profileSchema = baseProfileSchema.refine(
|
||||
|
||||
type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||
|
||||
// Helper to convert File to base64
|
||||
async function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to convert base64 to File
|
||||
function base64ToFile(base64: string, fileName: string, fileType: string): File {
|
||||
const arr = base64.split(',');
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n);
|
||||
}
|
||||
return new File([u8arr], fileName, { type: fileType });
|
||||
}
|
||||
|
||||
export function ProfileForm() {
|
||||
const platform = usePlatform();
|
||||
const open = useUIStore((state) => state.profileDialogOpen);
|
||||
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const editingProfileId = useUIStore((state) => state.editingProfileId);
|
||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||
const profileFormDraft = useUIStore((state) => state.profileFormDraft);
|
||||
const setProfileFormDraft = useUIStore((state) => state.setProfileFormDraft);
|
||||
const { data: editingProfile } = useProfile(editingProfileId || '');
|
||||
const createProfile = useCreateProfile();
|
||||
const updateProfile = useUpdateProfile();
|
||||
const addSample = useAddSample();
|
||||
const uploadAvatar = useUploadAvatar();
|
||||
const deleteAvatar = useDeleteAvatar();
|
||||
const transcribe = useTranscription();
|
||||
const { toast } = useToast();
|
||||
const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('upload');
|
||||
const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('record');
|
||||
const [audioDuration, setAudioDuration] = useState<number | null>(null);
|
||||
const [isValidatingAudio, setIsValidatingAudio] = useState(false);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
|
||||
const isCreating = !editingProfileId;
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
@@ -131,10 +134,12 @@ export function ProfileForm() {
|
||||
language: 'en',
|
||||
sampleFile: undefined,
|
||||
referenceText: '',
|
||||
avatarFile: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedFile = form.watch('sampleFile');
|
||||
const selectedAvatarFile = form.watch('avatarFile');
|
||||
|
||||
// Validate audio duration when file is selected
|
||||
useEffect(() => {
|
||||
@@ -186,7 +191,7 @@ export function ProfileForm() {
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
} = useAudioRecording({
|
||||
maxDurationSeconds: 30,
|
||||
maxDurationSeconds: 29,
|
||||
onRecordingComplete: (blob, recordedDuration) => {
|
||||
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
||||
type: blob.type || 'audio/webm',
|
||||
@@ -212,7 +217,7 @@ export function ProfileForm() {
|
||||
stopRecording: stopSystemRecording,
|
||||
cancelRecording: cancelSystemRecording,
|
||||
} = useSystemAudioCapture({
|
||||
maxDurationSeconds: 30,
|
||||
maxDurationSeconds: 29,
|
||||
onRecordingComplete: (blob, recordedDuration) => {
|
||||
const file = new File([blob], `system-audio-${Date.now()}.wav`, {
|
||||
type: blob.type || 'audio/wav',
|
||||
@@ -251,6 +256,20 @@ export function ProfileForm() {
|
||||
}
|
||||
}, [systemRecordingError, toast]);
|
||||
|
||||
// Handle avatar preview
|
||||
useEffect(() => {
|
||||
if (selectedAvatarFile instanceof File) {
|
||||
const url = URL.createObjectURL(selectedAvatarFile);
|
||||
setAvatarPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else if (editingProfile?.avatar_path) {
|
||||
setAvatarPreview(`${serverUrl}/profiles/${editingProfile.id}/avatar`);
|
||||
} else {
|
||||
setAvatarPreview(null);
|
||||
}
|
||||
}, [selectedAvatarFile, editingProfile, serverUrl]);
|
||||
|
||||
// Restore form state from draft or editing profile
|
||||
useEffect(() => {
|
||||
if (editingProfile) {
|
||||
form.reset({
|
||||
@@ -259,18 +278,46 @@ export function ProfileForm() {
|
||||
language: editingProfile.language as LanguageCode,
|
||||
sampleFile: undefined,
|
||||
referenceText: undefined,
|
||||
avatarFile: undefined,
|
||||
});
|
||||
} else {
|
||||
} else if (profileFormDraft && open) {
|
||||
// Restore from draft when opening in create mode
|
||||
form.reset({
|
||||
name: profileFormDraft.name,
|
||||
description: profileFormDraft.description,
|
||||
language: profileFormDraft.language as LanguageCode,
|
||||
referenceText: profileFormDraft.referenceText,
|
||||
sampleFile: undefined,
|
||||
avatarFile: undefined,
|
||||
});
|
||||
setSampleMode(profileFormDraft.sampleMode);
|
||||
// Restore the file if we have it saved
|
||||
if (
|
||||
profileFormDraft.sampleFileData &&
|
||||
profileFormDraft.sampleFileName &&
|
||||
profileFormDraft.sampleFileType
|
||||
) {
|
||||
const file = base64ToFile(
|
||||
profileFormDraft.sampleFileData,
|
||||
profileFormDraft.sampleFileName,
|
||||
profileFormDraft.sampleFileType,
|
||||
);
|
||||
form.setValue('sampleFile', file);
|
||||
}
|
||||
} else if (!open) {
|
||||
// Only reset to defaults when modal is closed and no draft
|
||||
form.reset({
|
||||
name: '',
|
||||
description: '',
|
||||
language: 'en',
|
||||
sampleFile: undefined,
|
||||
referenceText: undefined,
|
||||
avatarFile: undefined,
|
||||
});
|
||||
setSampleMode('upload');
|
||||
setSampleMode('record');
|
||||
setAvatarPreview(null);
|
||||
}
|
||||
}, [editingProfile, form]);
|
||||
}, [editingProfile, profileFormDraft, open, form]);
|
||||
|
||||
async function handleTranscribe() {
|
||||
const file = form.getValues('sampleFile');
|
||||
@@ -312,6 +359,52 @@ export function ProfileForm() {
|
||||
playPause(file);
|
||||
}
|
||||
|
||||
function handleAvatarFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please select an image file (PNG, JPG, or WebP)',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({
|
||||
title: 'File too large',
|
||||
description: 'Image must be less than 5MB',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
form.setValue('avatarFile', file);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveAvatar() {
|
||||
if (editingProfileId && editingProfile?.avatar_path) {
|
||||
try {
|
||||
await deleteAvatar.mutateAsync(editingProfileId);
|
||||
toast({
|
||||
title: 'Avatar removed',
|
||||
description: 'Avatar image has been removed successfully.',
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to remove avatar',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
form.setValue('avatarFile', undefined);
|
||||
setAvatarPreview(null);
|
||||
if (avatarInputRef.current) {
|
||||
avatarInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(data: ProfileFormValues) {
|
||||
try {
|
||||
if (editingProfileId) {
|
||||
@@ -324,8 +417,26 @@ export function ProfileForm() {
|
||||
language: data.language,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle avatar upload/update if file changed
|
||||
if (data.avatarFile) {
|
||||
try {
|
||||
await uploadAvatar.mutateAsync({
|
||||
profileId: editingProfileId,
|
||||
file: data.avatarFile,
|
||||
});
|
||||
} catch (avatarError) {
|
||||
toast({
|
||||
title: 'Avatar upload failed',
|
||||
description:
|
||||
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Profile updated',
|
||||
title: 'Voice updated',
|
||||
description: `"${data.name}" has been updated successfully.`,
|
||||
});
|
||||
} else {
|
||||
@@ -394,12 +505,43 @@ export function ProfileForm() {
|
||||
language: data.language,
|
||||
});
|
||||
|
||||
// Convert non-WAV uploads to WAV so the backend can always use soundfile.
|
||||
// Recorded audio is already WAV (from useAudioRecording's convertToWav call).
|
||||
let fileToUpload: File = sampleFile;
|
||||
if (!sampleFile.type.includes('wav') && !sampleFile.name.toLowerCase().endsWith('.wav')) {
|
||||
try {
|
||||
const wavBlob = await convertToWav(sampleFile);
|
||||
const wavName = sampleFile.name.replace(/\.[^.]+$/, '.wav');
|
||||
fileToUpload = new File([wavBlob], wavName, { type: 'audio/wav' });
|
||||
} catch {
|
||||
// If browser can't decode the format, send the original and let the backend try.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await addSample.mutateAsync({
|
||||
profileId: profile.id,
|
||||
file: sampleFile,
|
||||
file: fileToUpload,
|
||||
referenceText: referenceText,
|
||||
});
|
||||
|
||||
// Handle avatar upload if provided
|
||||
if (data.avatarFile) {
|
||||
try {
|
||||
await uploadAvatar.mutateAsync({
|
||||
profileId: profile.id,
|
||||
file: data.avatarFile,
|
||||
});
|
||||
} catch (avatarError) {
|
||||
toast({
|
||||
title: 'Avatar upload failed',
|
||||
description:
|
||||
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Profile created',
|
||||
description: `"${data.name}" has been created with a sample.`,
|
||||
@@ -414,6 +556,8 @@ export function ProfileForm() {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear draft and reset form on success
|
||||
setProfileFormDraft(null);
|
||||
form.reset();
|
||||
setEditingProfileId(null);
|
||||
setOpen(false);
|
||||
@@ -426,12 +570,41 @@ export function ProfileForm() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
async function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen && isCreating) {
|
||||
// Save draft when closing the create modal
|
||||
const values = form.getValues();
|
||||
const hasContent =
|
||||
values.name || values.description || values.referenceText || values.sampleFile;
|
||||
|
||||
if (hasContent) {
|
||||
const draft: ProfileFormDraft = {
|
||||
name: values.name || '',
|
||||
description: values.description || '',
|
||||
language: values.language || 'en',
|
||||
referenceText: values.referenceText || '',
|
||||
sampleMode,
|
||||
};
|
||||
|
||||
// Save file as base64 if present
|
||||
if (values.sampleFile) {
|
||||
try {
|
||||
draft.sampleFileName = values.sampleFile.name;
|
||||
draft.sampleFileType = values.sampleFile.type;
|
||||
draft.sampleFileData = await fileToBase64(values.sampleFile);
|
||||
} catch {
|
||||
// If file conversion fails, just don't save the file
|
||||
}
|
||||
}
|
||||
|
||||
setProfileFormDraft(draft);
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(newOpen);
|
||||
if (!newOpen) {
|
||||
setEditingProfileId(null);
|
||||
form.reset();
|
||||
setSampleMode('upload');
|
||||
// Don't reset form here - let the effect handle it based on draft state
|
||||
if (isRecording) {
|
||||
cancelRecording();
|
||||
}
|
||||
@@ -444,223 +617,310 @@ export function ProfileForm() {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingProfileId ? 'Edit Profile' : 'Create Voice Profile'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingProfileId
|
||||
? 'Update your voice profile details.'
|
||||
: 'Create a new voice profile with an audio sample to clone the voice.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className={`grid gap-6 ${isCreating ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{/* Left column: Profile info */}
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Voice" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Describe this voice..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogContent className="max-w-none w-screen h-screen left-0 top-0 translate-x-0 translate-y-0 rounded-none p-6 overflow-y-auto">
|
||||
<div className="max-w-5xl max-h-[85vh] mx-auto my-auto w-full flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">
|
||||
{editingProfileId ? 'Edit Voice' : 'Clone voice'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingProfileId
|
||||
? 'Update your voice profile details and manage samples.'
|
||||
: 'Create a new voice profile with an audio sample to clone the voice.'}
|
||||
</DialogDescription>
|
||||
{isCreating && profileFormDraft && (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<span className="text-xs text-muted-foreground">Draft restored</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => {
|
||||
setProfileFormDraft(null);
|
||||
form.reset({
|
||||
name: '',
|
||||
description: '',
|
||||
language: 'en',
|
||||
sampleFile: undefined,
|
||||
referenceText: '',
|
||||
});
|
||||
setSampleMode('record');
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{/* Right column: Sample upload section - only show when creating */}
|
||||
{isCreating && (
|
||||
<div className="space-y-4 border-l pl-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Add Sample</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Provide an audio sample to clone the voice. You can add more samples later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={sampleMode}
|
||||
onValueChange={(v) => {
|
||||
const newMode = v as 'upload' | 'record' | 'system';
|
||||
// Cancel any active recordings when switching modes
|
||||
if (isRecording && newMode !== 'record') {
|
||||
cancelRecording();
|
||||
}
|
||||
if (isSystemRecording && newMode !== 'system') {
|
||||
cancelSystemRecording();
|
||||
}
|
||||
setSampleMode(newMode);
|
||||
}}
|
||||
>
|
||||
<TabsList
|
||||
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
</TabsTrigger>
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="upload" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sampleFile"
|
||||
render={({ field: { onChange, name } }) => (
|
||||
<AudioSampleUpload
|
||||
file={selectedFile}
|
||||
onFileChange={onChange}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isValidating={isValidatingAudio}
|
||||
isTranscribing={transcribe.isPending}
|
||||
isDisabled={
|
||||
audioDuration !== null && audioDuration > MAX_AUDIO_DURATION_SECONDS
|
||||
}
|
||||
fieldName={name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="record" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sampleFile"
|
||||
render={() => (
|
||||
<AudioSampleRecording
|
||||
file={selectedFile}
|
||||
isRecording={isRecording}
|
||||
duration={duration}
|
||||
onStart={startRecording}
|
||||
onStop={stopRecording}
|
||||
onCancel={handleCancelRecording}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isTranscribing={transcribe.isPending}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
<TabsContent value="system" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sampleFile"
|
||||
render={() => (
|
||||
<AudioSampleSystem
|
||||
file={selectedFile}
|
||||
isRecording={isSystemRecording}
|
||||
duration={systemDuration}
|
||||
onStart={startSystemRecording}
|
||||
onStop={stopSystemRecording}
|
||||
onCancel={handleCancelRecording}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isTranscribing={transcribe.isPending}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="grid gap-6 grid-cols-2 flex-1 overflow-y-auto min-h-0">
|
||||
{/* Left column: Sample management */}
|
||||
<div className="space-y-4 border-r pr-6">
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Tabs
|
||||
className="pt-4"
|
||||
value={sampleMode}
|
||||
onValueChange={(v) => {
|
||||
const newMode = v as 'upload' | 'record' | 'system';
|
||||
// Cancel any active recordings when switching modes
|
||||
if (isRecording && newMode !== 'record') {
|
||||
cancelRecording();
|
||||
}
|
||||
if (isSystemRecording && newMode !== 'system') {
|
||||
cancelSystemRecording();
|
||||
}
|
||||
setSampleMode(newMode);
|
||||
}}
|
||||
>
|
||||
<TabsList
|
||||
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
</TabsTrigger>
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
</TabsTrigger>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="upload" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sampleFile"
|
||||
render={({ field: { onChange, name } }) => (
|
||||
<AudioSampleUpload
|
||||
file={selectedFile}
|
||||
onFileChange={onChange}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isValidating={isValidatingAudio}
|
||||
isTranscribing={transcribe.isPending}
|
||||
isDisabled={
|
||||
audioDuration !== null &&
|
||||
audioDuration > MAX_AUDIO_DURATION_SECONDS
|
||||
}
|
||||
fieldName={name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="record" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sampleFile"
|
||||
render={() => (
|
||||
<AudioSampleRecording
|
||||
file={selectedFile}
|
||||
isRecording={isRecording}
|
||||
duration={duration}
|
||||
onStart={startRecording}
|
||||
onStop={stopRecording}
|
||||
onCancel={handleCancelRecording}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isTranscribing={transcribe.isPending}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsContent value="system" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sampleFile"
|
||||
render={() => (
|
||||
<AudioSampleSystem
|
||||
file={selectedFile}
|
||||
isRecording={isSystemRecording}
|
||||
duration={systemDuration}
|
||||
onStart={startSystemRecording}
|
||||
onStop={stopSystemRecording}
|
||||
onCancel={handleCancelRecording}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isTranscribing={transcribe.isPending}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referenceText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reference Text</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter the exact text spoken in the audio..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Show sample list when editing
|
||||
editingProfileId && (
|
||||
<div>
|
||||
<SampleList profileId={editingProfileId} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column: Profile info */}
|
||||
<div className="space-y-4">
|
||||
{/* Avatar Upload */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referenceText"
|
||||
render={({ field }) => (
|
||||
name="avatarFile"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Reference Text</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter the exact text spoken in the audio..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
<div className="flex justify-center pt-4 pb-2">
|
||||
<div className="relative group">
|
||||
<div className="h-24 w-24 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden border-2 border-border">
|
||||
{avatarPreview ? (
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Avatar preview"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Mic className="h-10 w-10 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
className="absolute inset-0 rounded-full bg-accent/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<Edit2 className="h-6 w-6 text-accent-foreground" />
|
||||
</button>
|
||||
{(avatarPreview || editingProfile?.avatar_path) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveAvatar}
|
||||
disabled={deleteAvatar.isPending}
|
||||
className="absolute bottom-0 right-0 h-6 w-6 rounded-full bg-background/60 backdrop-blur-sm text-muted-foreground flex items-center justify-center hover:bg-background/80 hover:text-foreground transition-colors shadow-sm border border-border/50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={handleAvatarFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
|
||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createProfile.isPending || updateProfile.isPending || addSample.isPending}
|
||||
>
|
||||
{createProfile.isPending || updateProfile.isPending || addSample.isPending
|
||||
? 'Saving...'
|
||||
: editingProfileId
|
||||
? 'Update Profile'
|
||||
: 'Create Profile'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Voice" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Describe this voice..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
|
||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
createProfile.isPending || updateProfile.isPending || addSample.isPending
|
||||
}
|
||||
>
|
||||
{createProfile.isPending || updateProfile.isPending || addSample.isPending
|
||||
? 'Saving...'
|
||||
: editingProfileId
|
||||
? 'Save Changes'
|
||||
: 'Create Profile'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { Mic, Sparkles, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Mic, Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useImportProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { ProfileCard } from './ProfileCard';
|
||||
import { ProfileForm } from './ProfileForm';
|
||||
@@ -18,44 +9,6 @@ import { ProfileForm } from './ProfileForm';
|
||||
export function ProfileList() {
|
||||
const { data: profiles, isLoading, error } = useProfiles();
|
||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const importProfile = useImportProfile();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file extension
|
||||
if (!file.name.endsWith('.voicebox.zip')) {
|
||||
alert('Please select a valid .voicebox.zip file');
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setImportDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportConfirm = () => {
|
||||
if (selectedFile) {
|
||||
importProfile.mutate(selectedFile, {
|
||||
onSuccess: () => {
|
||||
setImportDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(`Failed to import profile: ${error.message}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
@@ -73,27 +26,6 @@ export function ProfileList() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4 shrink-0">
|
||||
<h2 className="text-2xl font-bold">Voicebox</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleImportClick}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import Voice
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".voicebox.zip"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Create Voice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{allProfiles.length === 0 ? (
|
||||
<Card>
|
||||
@@ -109,7 +41,7 @@ export function ProfileList() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1">
|
||||
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1 pb-[150px]">
|
||||
{allProfiles.map((profile) => (
|
||||
<ProfileCard key={profile.id} profile={profile} />
|
||||
))}
|
||||
@@ -118,38 +50,6 @@ export function ProfileList() {
|
||||
</div>
|
||||
|
||||
<ProfileForm />
|
||||
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Import the profile from "{selectedFile?.name}". This will create a new profile with
|
||||
all samples.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setImportDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportConfirm}
|
||||
disabled={importProfile.isPending || !selectedFile}
|
||||
>
|
||||
{importProfile.isPending ? 'Importing...' : 'Import'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,145 @@
|
||||
import { Plus, Trash2, Play } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { CircleButton } from '@/components/ui/circle-button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { SampleUpload } from './SampleUpload';
|
||||
|
||||
interface MiniSamplePlayerProps {
|
||||
audioUrl: string;
|
||||
}
|
||||
|
||||
function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = new Audio(audioUrl);
|
||||
audioRef.current = audio;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
setDuration(audio.duration);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
setCurrentTime(audio.currentTime);
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
};
|
||||
|
||||
const handlePlay = () => setIsPlaying(true);
|
||||
const handlePause = () => setIsPlaying(false);
|
||||
|
||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
audio.addEventListener('timeupdate', handleTimeUpdate);
|
||||
audio.addEventListener('ended', handleEnded);
|
||||
audio.addEventListener('play', handlePlay);
|
||||
audio.addEventListener('pause', handlePause);
|
||||
|
||||
return () => {
|
||||
audio.pause();
|
||||
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
audio.removeEventListener('ended', handleEnded);
|
||||
audio.removeEventListener('play', handlePlay);
|
||||
audio.removeEventListener('pause', handlePause);
|
||||
audio.src = '';
|
||||
};
|
||||
}, [audioUrl]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (!audioRef.current) return;
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
audioRef.current.play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (value: number[]) => {
|
||||
if (!audioRef.current || duration === 0) return;
|
||||
const progress = value[0] / 100;
|
||||
audioRef.current.currentTime = progress * duration;
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/30 px-3 py-2 mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading}
|
||||
aria-label={isPlaying ? 'Pause sample' : 'Play sample'}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<Slider
|
||||
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
|
||||
onValueChange={handleSeek}
|
||||
max={100}
|
||||
step={0.1}
|
||||
className="flex-1"
|
||||
aria-label="Sample playback position"
|
||||
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
|
||||
/>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
||||
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
||||
<span>/</span>
|
||||
<span className="font-mono">{formatAudioDuration(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handleStop}
|
||||
title="Stop"
|
||||
aria-label="Stop playback"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SampleListProps {
|
||||
profileId: string;
|
||||
}
|
||||
@@ -13,20 +147,62 @@ interface SampleListProps {
|
||||
export function SampleList({ profileId }: SampleListProps) {
|
||||
const { data: samples, isLoading } = useProfileSamples(profileId);
|
||||
const deleteSample = useDeleteSample();
|
||||
const updateSample = useUpdateSample();
|
||||
const { toast } = useToast();
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
||||
const [editingSampleId, setEditingSampleId] = useState<string | null>(null);
|
||||
const [editedText, setEditedText] = useState<string>('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [sampleToDelete, setSampleToDelete] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = (sampleId: string) => {
|
||||
if (confirm('Are you sure you want to delete this sample?')) {
|
||||
deleteSample.mutate(sampleId);
|
||||
const handleDeleteClick = (sampleId: string) => {
|
||||
setSampleToDelete(sampleId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (sampleToDelete) {
|
||||
deleteSample.mutate(sampleToDelete);
|
||||
setDeleteDialogOpen(false);
|
||||
setSampleToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = (referenceText: string, sampleId: string) => {
|
||||
const audioUrl = apiClient.getSampleUrl(sampleId);
|
||||
setAudio(audioUrl, sampleId, referenceText.substring(0, 50));
|
||||
const handleStartEdit = (sampleId: string, currentText: string) => {
|
||||
setEditingSampleId(sampleId);
|
||||
setEditedText(currentText);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingSampleId(null);
|
||||
setEditedText('');
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (sampleId: string) => {
|
||||
if (!editedText.trim()) {
|
||||
toast({
|
||||
title: 'Invalid text',
|
||||
description: 'Reference text cannot be empty.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSample.mutateAsync({ sampleId, referenceText: editedText.trim() });
|
||||
toast({
|
||||
title: 'Sample updated',
|
||||
description: 'Reference text has been updated successfully.',
|
||||
});
|
||||
setEditingSampleId(null);
|
||||
setEditedText('');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Update failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to update sample',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
@@ -34,55 +210,152 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Audio Samples</h3>
|
||||
<Button size="sm" onClick={() => setUploadOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Sample
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
{samples && samples.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-4">
|
||||
No samples yet. Add your first audio sample.
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
||||
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No samples yet</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Add your first audio sample to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{samples?.map((sample) => (
|
||||
<div
|
||||
key={sample.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{sample.reference_text}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{sample.audio_path}</p>
|
||||
{samples?.map((sample, index) => {
|
||||
const isEditing = editingSampleId === sample.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sample.id}
|
||||
className={cn(
|
||||
'group relative rounded-lg border bg-card transition-all duration-200',
|
||||
isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30',
|
||||
)}
|
||||
>
|
||||
{isEditing ? (
|
||||
/* Edit Mode */
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||
<Edit className="h-3 w-3" />
|
||||
<span>Editing transcription</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={editedText}
|
||||
onChange={(e) => setEditedText(e.target.value)}
|
||||
className="min-h-[100px] text-sm resize-none"
|
||||
placeholder="Enter reference text..."
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCancelEdit}
|
||||
disabled={updateSample.isPending}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => handleSaveEdit(sample.id)}
|
||||
disabled={updateSample.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
{updateSample.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* View Mode */}
|
||||
<div className="flex items-center gap-3 p-3 h-[72px]">
|
||||
{/* Text Content */}
|
||||
<div className="flex-1 min-w-0 py-0.5">
|
||||
<p className="text-sm font-medium line-clamp-2 leading-snug">
|
||||
{sample.reference_text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
title="Edit transcription"
|
||||
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Trash2}
|
||||
title="Delete sample"
|
||||
onClick={() => handleDeleteClick(sample.id)}
|
||||
disabled={deleteSample.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sample Number Badge */}
|
||||
<div className="absolute top-1 right-2 text-[10px] text-muted-foreground/50 font-medium">
|
||||
#{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Player - Always visible */}
|
||||
<MiniSamplePlayer audioUrl={apiClient.getSampleUrl(sample.id)} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePlay(sample.reference_text, sample.id)}
|
||||
className={currentAudioId === sample.id && isPlaying ? 'text-primary' : ''}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Play
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(sample.id)}
|
||||
disabled={deleteSample.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Sample
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center px-2">
|
||||
Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple
|
||||
samples. In a future update samples might be interchangeable and tagged for varying styles
|
||||
of the same voice.
|
||||
</p>
|
||||
|
||||
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Sample</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this audio sample? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setSampleToDelete(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteSample.isPending}
|
||||
>
|
||||
{deleteSample.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Mic, Monitor, Upload } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -22,15 +22,15 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||
|
||||
const sampleSchema = z.object({
|
||||
file: z.instanceof(File, { message: 'Please select an audio file' }),
|
||||
@@ -49,6 +49,7 @@ interface SampleUploadProps {
|
||||
}
|
||||
|
||||
export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) {
|
||||
const platform = usePlatform();
|
||||
const addSample = useAddSample();
|
||||
const transcribe = useTranscription();
|
||||
const { data: profile } = useProfile(profileId);
|
||||
@@ -73,7 +74,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
} = useAudioRecording({
|
||||
maxDurationSeconds: 30,
|
||||
maxDurationSeconds: 29,
|
||||
onRecordingComplete: (blob, recordedDuration) => {
|
||||
// Convert blob to File object
|
||||
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
||||
@@ -100,7 +101,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
stopRecording: stopSystemRecording,
|
||||
cancelRecording: cancelSystemRecording,
|
||||
} = useSystemAudioCapture({
|
||||
maxDurationSeconds: 30,
|
||||
maxDurationSeconds: 29,
|
||||
onRecordingComplete: (blob, recordedDuration) => {
|
||||
// Convert blob to File object
|
||||
const file = new File([blob], `system-audio-${Date.now()}.wav`, {
|
||||
@@ -232,7 +233,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record' | 'system')}>
|
||||
<TabsList
|
||||
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
@@ -242,7 +243,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
</TabsTrigger>
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
@@ -289,7 +290,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsContent value="system" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Edit, MoreHorizontal, Plus, Trash2, Mic } from 'lucide-react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { MultiSelect } from '@/components/ui/multi-select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { useHistory } from '@/lib/hooks/useHistory';
|
||||
import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
export function VoicesTab() {
|
||||
const { data: profiles, isLoading } = useProfiles();
|
||||
const { data: historyData } = useHistory({ limit: 1000 });
|
||||
const queryClient = useQueryClient();
|
||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||
const deleteProfile = useDeleteProfile();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
|
||||
// Get generation counts per profile
|
||||
const generationCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
if (historyData?.items) {
|
||||
historyData.items.forEach((item) => {
|
||||
counts[item.profile_id] = (counts[item.profile_id] || 0) + 1;
|
||||
});
|
||||
}
|
||||
return counts;
|
||||
}, [historyData]);
|
||||
|
||||
// Get channel assignments for each profile
|
||||
const { data: channelAssignments } = useQuery({
|
||||
queryKey: ['profile-channels'],
|
||||
queryFn: async () => {
|
||||
if (!profiles) return {};
|
||||
const assignments: Record<string, string[]> = {};
|
||||
for (const profile of profiles) {
|
||||
try {
|
||||
const result = await apiClient.getProfileChannels(profile.id);
|
||||
assignments[profile.id] = result.channel_ids;
|
||||
} catch {
|
||||
assignments[profile.id] = [];
|
||||
}
|
||||
}
|
||||
return assignments;
|
||||
},
|
||||
enabled: !!profiles,
|
||||
});
|
||||
|
||||
// Get all channels
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ['channels'],
|
||||
queryFn: () => apiClient.listChannels(),
|
||||
});
|
||||
|
||||
const handleEdit = (profileId: string) => {
|
||||
setEditingProfileId(profileId);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleProfileDelete = async (profileId: string) => {
|
||||
if (await confirm('Are you sure you want to delete this profile?')) {
|
||||
deleteProfile.mutate(profileId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChannelChange = async (profileId: string, channelIds: string[]) => {
|
||||
try {
|
||||
await apiClient.setProfileChannels(profileId, channelIds);
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||
} catch (error) {
|
||||
console.error('Failed to update channels:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">Loading voices...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col relative overflow-hidden">
|
||||
{/* 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-10 pointer-events-none" />
|
||||
|
||||
{/* Fixed Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Voices</h1>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Voice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto pt-16 relative z-0',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Language</TableHead>
|
||||
<TableHead>Generations</TableHead>
|
||||
<TableHead>Samples</TableHead>
|
||||
<TableHead>Channels</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profiles?.map((profile) => (
|
||||
<VoiceRow
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
generationCount={generationCounts[profile.id] || 0}
|
||||
channelIds={channelAssignments?.[profile.id] || []}
|
||||
channels={channels || []}
|
||||
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
|
||||
onEdit={() => handleEdit(profile.id)}
|
||||
onDelete={() => handleProfileDelete(profile.id)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<ProfileForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VoiceRowProps {
|
||||
profile: VoiceProfileResponse;
|
||||
generationCount: number;
|
||||
channelIds: string[];
|
||||
channels: Array<{ id: string; name: string; is_default: boolean }>;
|
||||
onChannelChange: (channelIds: string[]) => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function VoiceRow({
|
||||
profile,
|
||||
generationCount,
|
||||
channelIds,
|
||||
channels,
|
||||
onChannelChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: VoiceRowProps) {
|
||||
const { data: samples } = useProfileSamples(profile.id);
|
||||
const sampleCount = samples?.length || 0;
|
||||
|
||||
const rowLabel = `${profile.name}, ${profile.language}, ${generationCount} generations, ${sampleCount} samples. Press Enter to edit.`;
|
||||
|
||||
return (
|
||||
<TableRow className="cursor-pointer" onClick={onEdit}>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-2 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
|
||||
aria-label={rowLabel}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{profile.name}</div>
|
||||
{profile.description && (
|
||||
<div className="text-sm text-muted-foreground truncate">{profile.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>{profile.language}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>{generationCount}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>{sampleCount}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<MultiSelect
|
||||
options={channels.map((ch) => ({
|
||||
value: ch.id,
|
||||
label: `${ch.name}${ch.is_default ? ' (Default)' : ''}`,
|
||||
}))}
|
||||
value={channelIds}
|
||||
onChange={onChannelChange}
|
||||
placeholder="Select channels..."
|
||||
className="min-w-[200px]"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label={`Actions for ${profile.name}`}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
export interface CheckboxProps {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, onCheckedChange, ...props }, ref) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(e.target.checked);
|
||||
}
|
||||
// Call original onChange if provided
|
||||
if (props.onChange) {
|
||||
props.onChange(e);
|
||||
}
|
||||
};
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
({ checked = false, onCheckedChange, disabled = false, className, id, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
id={id}
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled && onCheckedChange) {
|
||||
onCheckedChange(!checked);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0 transition-colors',
|
||||
checked ? 'bg-accent border-accent' : 'border-muted-foreground/30',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
!disabled && 'cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{checked && <Check className="h-3 w-3 text-accent-foreground" />}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,10 +6,11 @@ export interface CircleButtonProps extends React.ButtonHTMLAttributes<HTMLButton
|
||||
}
|
||||
|
||||
const CircleButton = React.forwardRef<HTMLButtonElement, CircleButtonProps>(
|
||||
({ className, icon: Icon, ...props }, ref) => {
|
||||
({ className, icon: Icon, type = 'button', ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
'hover:bg-muted transition-colors',
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronDown, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
|
||||
export interface MultiSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface MultiSelectProps {
|
||||
options: MultiSelectOption[];
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MultiSelectCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
MultiSelectCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
export function MultiSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select...',
|
||||
className,
|
||||
}: MultiSelectProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
const newValue = value.includes(optionValue)
|
||||
? value.filter((v) => v !== optionValue)
|
||||
: [...value, optionValue];
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const displayText =
|
||||
value.length === 0
|
||||
? placeholder
|
||||
: value.length === 1
|
||||
? options.find((opt) => opt.value === value[0])?.label || placeholder
|
||||
: `${value.length} selected`;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 w-full items-center justify-between rounded-full border border-border bg-card px-3 py-2 text-xs ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 hover:bg-background/50 transition-all',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="line-clamp-1">{displayText}</span>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="max-h-96 overflow-auto"
|
||||
align="start"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<MultiSelectCheckboxItem
|
||||
key={option.value}
|
||||
checked={value.includes(option.value)}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
onCheckedChange={() => handleSelect(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</MultiSelectCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
interface Window {
|
||||
__voiceboxServerStartedByApp?: boolean;
|
||||
}
|
||||
@@ -1,112 +1,52 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { check, type Update } from '@tauri-apps/plugin-updater';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import type { UpdateStatus } from '@/platform/types';
|
||||
|
||||
export interface UpdateStatus {
|
||||
checking: boolean;
|
||||
available: boolean;
|
||||
version?: string;
|
||||
downloading: boolean;
|
||||
installing: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const isTauri = () => {
|
||||
return '__TAURI_INTERNALS__' in window;
|
||||
};
|
||||
// Re-export UpdateStatus for backwards compatibility
|
||||
export type { UpdateStatus };
|
||||
|
||||
export function useAutoUpdater(checkOnMount = false) {
|
||||
const [status, setStatus] = useState<UpdateStatus>({
|
||||
checking: false,
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
});
|
||||
const platform = usePlatform();
|
||||
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||
const hasCheckedRef = useRef(false);
|
||||
|
||||
const [update, setUpdate] = useState<Update | 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 = async () => {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
await platform.updater.checkForUpdates();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.checkForUpdates]);
|
||||
|
||||
try {
|
||||
setStatus((prev) => ({ ...prev, checking: true, error: undefined }));
|
||||
const downloadAndInstall = useCallback(async () => {
|
||||
await platform.updater.downloadAndInstall();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.downloadAndInstall]);
|
||||
|
||||
const foundUpdate = await check();
|
||||
|
||||
if (foundUpdate?.available) {
|
||||
setUpdate(foundUpdate);
|
||||
setStatus({
|
||||
checking: false,
|
||||
available: true,
|
||||
version: foundUpdate.version,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
});
|
||||
} else {
|
||||
setStatus({
|
||||
checking: false,
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
checking: false,
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAndInstall = async () => {
|
||||
if (!update || !isTauri()) return;
|
||||
|
||||
try {
|
||||
setStatus((prev) => ({ ...prev, downloading: true, error: undefined }));
|
||||
|
||||
await update.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
setStatus((prev) => ({ ...prev, downloading: true }));
|
||||
break;
|
||||
case 'Progress':
|
||||
console.log(`Downloaded ${event.data.chunkLength} bytes`);
|
||||
break;
|
||||
case 'Finished':
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
downloading: false,
|
||||
installing: true,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
await relaunch();
|
||||
} catch (error) {
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to install update',
|
||||
}));
|
||||
}
|
||||
};
|
||||
const restartAndInstall = useCallback(async () => {
|
||||
await platform.updater.restartAndInstall();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.updater.restartAndInstall]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkOnMount && isTauri()) {
|
||||
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||
hasCheckedRef.current = true;
|
||||
checkForUpdates();
|
||||
}
|
||||
}, [checkOnMount]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||
|
||||
return {
|
||||
status,
|
||||
checkForUpdates,
|
||||
downloadAndInstall,
|
||||
restartAndInstall,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+305
-12
@@ -1,18 +1,30 @@
|
||||
import type { LanguageCode } from '@/lib/constants/languages';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import type {
|
||||
VoiceProfileCreate,
|
||||
VoiceProfileResponse,
|
||||
ProfileSampleResponse,
|
||||
ActiveTasksResponse,
|
||||
CudaStatus,
|
||||
GenerationRequest,
|
||||
GenerationResponse,
|
||||
HistoryQuery,
|
||||
HistoryListResponse,
|
||||
HistoryResponse,
|
||||
TranscriptionResponse,
|
||||
HealthResponse,
|
||||
ModelStatusListResponse,
|
||||
HistoryListResponse,
|
||||
HistoryQuery,
|
||||
HistoryResponse,
|
||||
ModelDownloadRequest,
|
||||
ActiveTasksResponse,
|
||||
ModelStatusListResponse,
|
||||
ProfileSampleResponse,
|
||||
StoryCreate,
|
||||
StoryDetailResponse,
|
||||
StoryItemBatchUpdate,
|
||||
StoryItemCreate,
|
||||
StoryItemDetail,
|
||||
StoryItemMove,
|
||||
StoryItemReorder,
|
||||
StoryItemSplit,
|
||||
StoryItemTrim,
|
||||
StoryResponse,
|
||||
TranscriptionResponse,
|
||||
VoiceProfileCreate,
|
||||
VoiceProfileResponse,
|
||||
} from './types';
|
||||
|
||||
class ApiClient {
|
||||
@@ -110,6 +122,16 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async updateProfileSample(
|
||||
sampleId: string,
|
||||
referenceText: string,
|
||||
): Promise<ProfileSampleResponse> {
|
||||
return this.request<ProfileSampleResponse>(`/profiles/samples/${sampleId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ reference_text: referenceText }),
|
||||
});
|
||||
}
|
||||
|
||||
async exportProfile(profileId: string): Promise<Blob> {
|
||||
const url = `${this.getBaseUrl()}/profiles/${profileId}/export`;
|
||||
const response = await fetch(url);
|
||||
@@ -144,6 +166,32 @@ class ApiClient {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async uploadAvatar(profileId: string, file: File): Promise<VoiceProfileResponse> {
|
||||
const url = `${this.getBaseUrl()}/profiles/${profileId}/avatar`;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async deleteAvatar(profileId: string): Promise<void> {
|
||||
await this.request<void>(`/profiles/${profileId}/avatar`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// Generation
|
||||
async generateSpeech(data: GenerationRequest): Promise<GenerationResponse> {
|
||||
return this.request<GenerationResponse>('/generate', {
|
||||
@@ -204,7 +252,13 @@ class ApiClient {
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async importGeneration(file: File): Promise<{ id: string; profile_id: string; profile_name: string; text: string; message: string }> {
|
||||
async importGeneration(file: File): Promise<{
|
||||
id: string;
|
||||
profile_id: string;
|
||||
profile_name: string;
|
||||
text: string;
|
||||
message: string;
|
||||
}> {
|
||||
const url = `${this.getBaseUrl()}/history/import`;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
@@ -234,7 +288,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// Transcription
|
||||
async transcribeAudio(file: File, language?: 'en' | 'zh'): Promise<TranscriptionResponse> {
|
||||
async transcribeAudio(file: File, language?: LanguageCode): Promise<TranscriptionResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (language) {
|
||||
@@ -262,11 +316,34 @@ class ApiClient {
|
||||
return this.request<ModelStatusListResponse>('/models/status');
|
||||
}
|
||||
|
||||
async getModelsCacheDir(): Promise<{ path: string }> {
|
||||
return this.request<{ path: string }>('/models/cache-dir');
|
||||
}
|
||||
|
||||
async migrateModels(destination: string): Promise<{ source: string; destination: string }> {
|
||||
return this.request('/models/migrate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ destination }),
|
||||
});
|
||||
}
|
||||
|
||||
getMigrationProgressUrl(): string {
|
||||
return `${this.getBaseUrl()}/models/migrate/progress`;
|
||||
}
|
||||
|
||||
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
|
||||
return this.request<{ message: string }>('/models/download', {
|
||||
console.log(
|
||||
'[API] triggerModelDownload called for:',
|
||||
modelName,
|
||||
'at',
|
||||
new Date().toISOString(),
|
||||
);
|
||||
const result = await this.request<{ message: string }>('/models/download', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
||||
});
|
||||
console.log('[API] triggerModelDownload response:', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteModel(modelName: string): Promise<{ message: string }> {
|
||||
@@ -275,10 +352,226 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async unloadModel(modelName: string): Promise<{ message: string }> {
|
||||
return this.request<{ message: string }>(`/models/${modelName}/unload`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async cancelDownload(modelName: string): Promise<{ message: string }> {
|
||||
return this.request<{ message: string }>('/models/download/cancel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
||||
});
|
||||
}
|
||||
|
||||
// Task Management
|
||||
async getActiveTasks(): Promise<ActiveTasksResponse> {
|
||||
return this.request<ActiveTasksResponse>('/tasks/active');
|
||||
}
|
||||
|
||||
async clearAllTasks(): Promise<{ message: string }> {
|
||||
return this.request<{ message: string }>('/tasks/clear', { method: 'POST' });
|
||||
}
|
||||
|
||||
// Audio Channels
|
||||
async listChannels(): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
device_ids: string[];
|
||||
created_at: string;
|
||||
}>
|
||||
> {
|
||||
return this.request('/channels');
|
||||
}
|
||||
|
||||
async createChannel(data: { name: string; device_ids: string[] }): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
device_ids: string[];
|
||||
created_at: string;
|
||||
}> {
|
||||
return this.request('/channels', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateChannel(
|
||||
channelId: string,
|
||||
data: {
|
||||
name?: string;
|
||||
device_ids?: string[];
|
||||
},
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
device_ids: string[];
|
||||
created_at: string;
|
||||
}> {
|
||||
return this.request(`/channels/${channelId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteChannel(channelId: string): Promise<{ message: string }> {
|
||||
return this.request(`/channels/${channelId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async getChannelVoices(channelId: string): Promise<{ profile_ids: string[] }> {
|
||||
return this.request(`/channels/${channelId}/voices`);
|
||||
}
|
||||
|
||||
async setChannelVoices(channelId: string, profileIds: string[]): Promise<{ message: string }> {
|
||||
return this.request(`/channels/${channelId}/voices`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ profile_ids: profileIds }),
|
||||
});
|
||||
}
|
||||
|
||||
async getProfileChannels(profileId: string): Promise<{ channel_ids: string[] }> {
|
||||
return this.request(`/profiles/${profileId}/channels`);
|
||||
}
|
||||
|
||||
async setProfileChannels(profileId: string, channelIds: string[]): Promise<{ message: string }> {
|
||||
return this.request(`/profiles/${profileId}/channels`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ channel_ids: channelIds }),
|
||||
});
|
||||
}
|
||||
|
||||
// CUDA Backend Management
|
||||
async getCudaStatus(): Promise<CudaStatus> {
|
||||
return this.request<CudaStatus>('/backend/cuda-status');
|
||||
}
|
||||
|
||||
async downloadCudaBackend(): Promise<{ message: string; progress_key: string }> {
|
||||
return this.request<{ message: string; progress_key: string }>('/backend/download-cuda', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCudaBackend(): Promise<{ message: string }> {
|
||||
return this.request<{ message: string }>('/backend/cuda', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// Stories
|
||||
async listStories(): Promise<StoryResponse[]> {
|
||||
return this.request<StoryResponse[]>('/stories');
|
||||
}
|
||||
|
||||
async createStory(data: StoryCreate): Promise<StoryResponse> {
|
||||
return this.request<StoryResponse>('/stories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async getStory(storyId: string): Promise<StoryDetailResponse> {
|
||||
return this.request<StoryDetailResponse>(`/stories/${storyId}`);
|
||||
}
|
||||
|
||||
async updateStory(storyId: string, data: StoryCreate): Promise<StoryResponse> {
|
||||
return this.request<StoryResponse>(`/stories/${storyId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteStory(storyId: string): Promise<void> {
|
||||
await this.request<void>(`/stories/${storyId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async addStoryItem(storyId: string, data: StoryItemCreate): Promise<StoryItemDetail> {
|
||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async removeStoryItem(storyId: string, itemId: string): Promise<void> {
|
||||
await this.request<void>(`/stories/${storyId}/items/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async updateStoryItemTimes(storyId: string, data: StoryItemBatchUpdate): Promise<void> {
|
||||
await this.request<void>(`/stories/${storyId}/items/times`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async reorderStoryItems(storyId: string, data: StoryItemReorder): Promise<StoryItemDetail[]> {
|
||||
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/reorder`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async moveStoryItem(
|
||||
storyId: string,
|
||||
itemId: string,
|
||||
data: StoryItemMove,
|
||||
): Promise<StoryItemDetail> {
|
||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async trimStoryItem(
|
||||
storyId: string,
|
||||
itemId: string,
|
||||
data: StoryItemTrim,
|
||||
): Promise<StoryItemDetail> {
|
||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/trim`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async splitStoryItem(
|
||||
storyId: string,
|
||||
itemId: string,
|
||||
data: StoryItemSplit,
|
||||
): Promise<StoryItemDetail[]> {
|
||||
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/${itemId}/split`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async duplicateStoryItem(storyId: string, itemId: string): Promise<StoryItemDetail> {
|
||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/duplicate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async exportStoryAudio(storyId: string): Promise<Blob> {
|
||||
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ModelStatus = {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
downloaded: boolean;
|
||||
downloading?: boolean; // True if download is in progress
|
||||
size_mb?: number | null;
|
||||
loaded?: boolean;
|
||||
};
|
||||
|
||||
+131
-3
@@ -1,9 +1,10 @@
|
||||
// API Types matching backend Pydantic models
|
||||
import type { LanguageCode } from '@/lib/constants/languages';
|
||||
|
||||
export interface VoiceProfileCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
language: 'en' | 'zh';
|
||||
language: LanguageCode;
|
||||
}
|
||||
|
||||
export interface VoiceProfileResponse {
|
||||
@@ -11,6 +12,7 @@ export interface VoiceProfileResponse {
|
||||
name: string;
|
||||
description?: string;
|
||||
language: string;
|
||||
avatar_path?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -29,9 +31,14 @@ export interface ProfileSampleResponse {
|
||||
export interface GenerationRequest {
|
||||
profile_id: string;
|
||||
text: string;
|
||||
language: 'en' | 'zh';
|
||||
language: LanguageCode;
|
||||
seed?: number;
|
||||
model_size?: '1.7B' | '0.6B';
|
||||
engine?: 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo';
|
||||
instruct?: string;
|
||||
max_chunk_chars?: number;
|
||||
crossfade_ms?: number;
|
||||
normalize?: boolean;
|
||||
}
|
||||
|
||||
export interface GenerationResponse {
|
||||
@@ -62,7 +69,7 @@ export interface HistoryListResponse {
|
||||
}
|
||||
|
||||
export interface TranscriptionRequest {
|
||||
language?: 'en' | 'zh';
|
||||
language?: LanguageCode;
|
||||
}
|
||||
|
||||
export interface TranscriptionResponse {
|
||||
@@ -76,7 +83,29 @@ export interface HealthResponse {
|
||||
model_downloaded?: boolean;
|
||||
model_size?: string;
|
||||
gpu_available: boolean;
|
||||
gpu_type?: string;
|
||||
vram_used_mb?: number;
|
||||
backend_type?: string;
|
||||
backend_variant?: string; // "cpu" or "cuda"
|
||||
}
|
||||
|
||||
export interface CudaDownloadProgress {
|
||||
model_name: string;
|
||||
current: number;
|
||||
total: number;
|
||||
progress: number;
|
||||
filename?: string;
|
||||
status: 'downloading' | 'extracting' | 'complete' | 'error';
|
||||
timestamp: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CudaStatus {
|
||||
available: boolean; // CUDA binary exists on disk
|
||||
active: boolean; // Currently running the CUDA binary
|
||||
binary_path?: string;
|
||||
downloading: boolean; // Download in progress
|
||||
download_progress?: CudaDownloadProgress;
|
||||
}
|
||||
|
||||
export interface ModelProgress {
|
||||
@@ -93,11 +122,29 @@ export interface ModelProgress {
|
||||
export interface ModelStatus {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
hf_repo_id?: string; // HuggingFace repository ID
|
||||
downloaded: boolean;
|
||||
downloading: boolean; // True if download is in progress
|
||||
size_mb?: number;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
export interface HuggingFaceModelInfo {
|
||||
id: string;
|
||||
author: string;
|
||||
lastModified: string;
|
||||
pipeline_tag?: string;
|
||||
library_name?: string;
|
||||
downloads: number;
|
||||
likes: number;
|
||||
tags: string[];
|
||||
cardData?: {
|
||||
license?: string;
|
||||
language?: string[];
|
||||
pipeline_tag?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ModelStatusListResponse {
|
||||
models: ModelStatus[];
|
||||
}
|
||||
@@ -110,6 +157,11 @@ export interface ActiveDownloadTask {
|
||||
model_name: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
error?: string;
|
||||
progress?: number; // 0-100 percentage
|
||||
current?: number; // bytes downloaded
|
||||
total?: number; // total bytes
|
||||
filename?: string; // current file being downloaded
|
||||
}
|
||||
|
||||
export interface ActiveGenerationTask {
|
||||
@@ -123,3 +175,79 @@ export interface ActiveTasksResponse {
|
||||
downloads: ActiveDownloadTask[];
|
||||
generations: ActiveGenerationTask[];
|
||||
}
|
||||
|
||||
export interface StoryCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface StoryResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
item_count: number;
|
||||
}
|
||||
|
||||
export interface StoryItemDetail {
|
||||
id: string;
|
||||
story_id: string;
|
||||
generation_id: string;
|
||||
start_time_ms: number;
|
||||
track: number;
|
||||
trim_start_ms: number;
|
||||
trim_end_ms: number;
|
||||
created_at: string;
|
||||
profile_id: string;
|
||||
profile_name: string;
|
||||
text: string;
|
||||
language: string;
|
||||
audio_path: string;
|
||||
duration: number;
|
||||
seed?: number;
|
||||
instruct?: string;
|
||||
generation_created_at: string;
|
||||
}
|
||||
|
||||
export interface StoryDetailResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
items: StoryItemDetail[];
|
||||
}
|
||||
|
||||
export interface StoryItemCreate {
|
||||
generation_id: string;
|
||||
start_time_ms?: number;
|
||||
track?: number;
|
||||
}
|
||||
|
||||
export interface StoryItemUpdateTime {
|
||||
generation_id: string;
|
||||
start_time_ms: number;
|
||||
}
|
||||
|
||||
export interface StoryItemBatchUpdate {
|
||||
updates: StoryItemUpdateTime[];
|
||||
}
|
||||
|
||||
export interface StoryItemReorder {
|
||||
generation_ids: string[];
|
||||
}
|
||||
|
||||
export interface StoryItemMove {
|
||||
start_time_ms: number;
|
||||
track: number;
|
||||
}
|
||||
|
||||
export interface StoryItemTrim {
|
||||
trim_start_ms: number;
|
||||
trim_end_ms: number;
|
||||
}
|
||||
|
||||
export interface StoryItemSplit {
|
||||
split_time_ms: number;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,86 @@
|
||||
/**
|
||||
* Supported languages for Qwen3-TTS
|
||||
* Based on: https://github.com/QwenLM/Qwen3-TTS
|
||||
* Supported languages for voice generation, per engine.
|
||||
*
|
||||
* Qwen3-TTS supports 10 languages.
|
||||
* LuxTTS is English-only.
|
||||
* Chatterbox Multilingual supports 23 languages.
|
||||
* Chatterbox Turbo is English-only.
|
||||
*/
|
||||
|
||||
export const SUPPORTED_LANGUAGES = {
|
||||
zh: 'Chinese',
|
||||
/** All languages that any engine supports. */
|
||||
export const ALL_LANGUAGES = {
|
||||
ar: 'Arabic',
|
||||
da: 'Danish',
|
||||
de: 'German',
|
||||
el: 'Greek',
|
||||
en: 'English',
|
||||
es: 'Spanish',
|
||||
fi: 'Finnish',
|
||||
fr: 'French',
|
||||
he: 'Hebrew',
|
||||
hi: 'Hindi',
|
||||
it: 'Italian',
|
||||
ja: 'Japanese',
|
||||
ko: 'Korean',
|
||||
de: 'German',
|
||||
fr: 'French',
|
||||
ru: 'Russian',
|
||||
ms: 'Malay',
|
||||
nl: 'Dutch',
|
||||
no: 'Norwegian',
|
||||
pl: 'Polish',
|
||||
pt: 'Portuguese',
|
||||
es: 'Spanish',
|
||||
it: 'Italian',
|
||||
ru: 'Russian',
|
||||
sv: 'Swedish',
|
||||
sw: 'Swahili',
|
||||
tr: 'Turkish',
|
||||
zh: 'Chinese',
|
||||
} 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) => ({
|
||||
value: code,
|
||||
label: SUPPORTED_LANGUAGES[code],
|
||||
label: ALL_LANGUAGES[code],
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* UI layout constants for safe area padding
|
||||
*/
|
||||
|
||||
/**
|
||||
* Top safe area padding - height of the drag region bar
|
||||
* Corresponds to Tailwind's pt-12 (3rem / 48px)
|
||||
*/
|
||||
export const TOP_SAFE_AREA_PADDING = 'pt-12';
|
||||
|
||||
/**
|
||||
* Bottom safe area padding - height of the audio player
|
||||
* Corresponds to Tailwind's pb-32 (8rem / 128px)
|
||||
*/
|
||||
export const BOTTOM_SAFE_AREA_PADDING = 'pb-32';
|
||||
@@ -1 +0,0 @@
|
||||
# React Query hooks will be placed here
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { convertToWav } from '@/lib/utils/audio';
|
||||
|
||||
interface UseAudioRecordingOptions {
|
||||
maxDurationSeconds?: number;
|
||||
@@ -7,9 +8,10 @@ interface UseAudioRecordingOptions {
|
||||
}
|
||||
|
||||
export function useAudioRecording({
|
||||
maxDurationSeconds = 30,
|
||||
maxDurationSeconds = 29,
|
||||
onRecordingComplete,
|
||||
}: UseAudioRecordingOptions = {}) {
|
||||
const platform = usePlatform();
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -18,11 +20,13 @@ export function useAudioRecording({
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
const cancelledRef = useRef<boolean>(false);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
chunksRef.current = [];
|
||||
cancelledRef.current = false;
|
||||
setDuration(0);
|
||||
|
||||
// Check if getUserMedia is available
|
||||
@@ -39,15 +43,14 @@ export function useAudioRecording({
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
const isTauriEnv = isTauri();
|
||||
console.error('MediaDevices check:', {
|
||||
hasNavigator: typeof navigator !== 'undefined',
|
||||
hasMediaDevices: !!navigator?.mediaDevices,
|
||||
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
|
||||
isTauri: isTauriEnv,
|
||||
isTauri: platform.metadata.isTauri,
|
||||
});
|
||||
|
||||
const errorMsg = isTauriEnv
|
||||
const errorMsg = platform.metadata.isTauri
|
||||
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
|
||||
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
|
||||
setError(errorMsg);
|
||||
@@ -85,19 +88,35 @@ export function useAudioRecording({
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
// Pass the actual recorded duration
|
||||
const recordedDuration = startTimeRef.current
|
||||
? (Date.now() - startTimeRef.current) / 1000
|
||||
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;
|
||||
onRecordingComplete?.(blob, recordedDuration);
|
||||
|
||||
// Stop all tracks
|
||||
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
|
||||
// Stop all tracks now that we have the data
|
||||
streamRef.current?.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
streamRef.current = null;
|
||||
|
||||
// Don't fire completion callback if the recording was cancelled
|
||||
if (wasCancelled) return;
|
||||
|
||||
// Convert to WAV format to avoid needing ffmpeg on backend
|
||||
try {
|
||||
const wavBlob = await convertToWav(webmBlob);
|
||||
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) => {
|
||||
@@ -153,9 +172,10 @@ export function useAudioRecording({
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current) {
|
||||
cancelledRef.current = true; // Must be set before stop() triggers onstop
|
||||
chunksRef.current = [];
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
chunksRef.current = [];
|
||||
setDuration(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
|
||||
import { useGeneration } from '@/lib/hooks/useGeneration';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
const generationSchema = z.object({
|
||||
text: z.string().min(1, 'Text is required').max(50000),
|
||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||
seed: z.number().int().optional(),
|
||||
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
||||
instruct: z.string().max(500).optional(),
|
||||
engine: z.enum(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo']).optional(),
|
||||
});
|
||||
|
||||
export type GenerationFormValues = z.infer<typeof generationSchema>;
|
||||
|
||||
interface UseGenerationFormOptions {
|
||||
onSuccess?: (generationId: string) => void;
|
||||
defaultValues?: Partial<GenerationFormValues>;
|
||||
}
|
||||
|
||||
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||
const { toast } = useToast();
|
||||
const generation = useGeneration();
|
||||
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||
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 [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||
|
||||
useModelDownloadToast({
|
||||
modelName: downloadingModelName || '',
|
||||
displayName: downloadingDisplayName || '',
|
||||
enabled: !!downloadingModelName,
|
||||
});
|
||||
|
||||
const form = useForm<GenerationFormValues>({
|
||||
resolver: zodResolver(generationSchema),
|
||||
defaultValues: {
|
||||
text: '',
|
||||
language: 'en',
|
||||
seed: undefined,
|
||||
modelSize: '1.7B',
|
||||
instruct: '',
|
||||
engine: 'qwen',
|
||||
...options.defaultValues,
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSubmit(
|
||||
data: GenerationFormValues,
|
||||
selectedProfileId: string | null,
|
||||
): Promise<void> {
|
||||
if (!selectedProfileId) {
|
||||
toast({
|
||||
title: 'No profile selected',
|
||||
description: 'Please select a voice profile from the cards above.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
const engine = data.engine || 'qwen';
|
||||
const modelName =
|
||||
engine === 'luxtts'
|
||||
? 'luxtts'
|
||||
: engine === 'chatterbox'
|
||||
? 'chatterbox-tts'
|
||||
: engine === 'chatterbox_turbo'
|
||||
? 'chatterbox-turbo'
|
||||
: `qwen-tts-${data.modelSize}`;
|
||||
const displayName =
|
||||
engine === 'luxtts'
|
||||
? 'LuxTTS'
|
||||
: engine === 'chatterbox'
|
||||
? 'Chatterbox TTS'
|
||||
: engine === 'chatterbox_turbo'
|
||||
? 'Chatterbox Turbo'
|
||||
: data.modelSize === '1.7B'
|
||||
? 'Qwen TTS 1.7B'
|
||||
: 'Qwen TTS 0.6B';
|
||||
|
||||
try {
|
||||
const modelStatus = await apiClient.getModelStatus();
|
||||
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
||||
|
||||
if (model && !model.downloaded) {
|
||||
setDownloadingModelName(modelName);
|
||||
setDownloadingDisplayName(displayName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check model status:', error);
|
||||
}
|
||||
|
||||
const isQwen = engine === 'qwen';
|
||||
const result = await generation.mutateAsync({
|
||||
profile_id: selectedProfileId,
|
||||
text: data.text,
|
||||
language: data.language,
|
||||
seed: data.seed,
|
||||
model_size: isQwen ? data.modelSize : undefined,
|
||||
engine,
|
||||
instruct: isQwen ? data.instruct || undefined : undefined,
|
||||
max_chunk_chars: maxChunkChars,
|
||||
crossfade_ms: crossfadeMs,
|
||||
normalize: normalizeAudio,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Generation complete!',
|
||||
description: `Audio generated (${result.duration.toFixed(2)}s)`,
|
||||
});
|
||||
|
||||
const audioUrl = apiClient.getAudioUrl(result.id);
|
||||
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
||||
|
||||
form.reset({
|
||||
text: '',
|
||||
language: data.language,
|
||||
seed: undefined,
|
||||
modelSize: data.modelSize,
|
||||
instruct: '',
|
||||
engine: data.engine,
|
||||
});
|
||||
options.onSuccess?.(result.id);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Generation failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to generate audio',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
setDownloadingModelName(null);
|
||||
setDownloadingDisplayName(null);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
handleSubmit,
|
||||
isPending: generation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { HistoryQuery } from '@/lib/api/types';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function useHistory(query?: HistoryQuery) {
|
||||
return useQuery({
|
||||
@@ -30,116 +30,52 @@ export function useDeleteGeneration() {
|
||||
}
|
||||
|
||||
export function useExportGeneration() {
|
||||
const platform = usePlatform();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||
const blob = await apiClient.exportGeneration(generationId);
|
||||
|
||||
|
||||
// Create safe filename from text
|
||||
const safeText = text.substring(0, 30).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const safeText = text
|
||||
.substring(0, 30)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `generation-${safeText}.voicebox.zip`;
|
||||
|
||||
if (isTauri()) {
|
||||
// Use Tauri's native save dialog
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const filePath = await save({
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{
|
||||
name: 'Voicebox Generation',
|
||||
extensions: ['voicebox.zip', 'zip'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
// Write file using Tauri's filesystem API
|
||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
||||
// Fall back to browser download if Tauri dialog fails
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} else {
|
||||
// Browser: trigger download
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
name: 'Voicebox Generation',
|
||||
extensions: ['zip'],
|
||||
},
|
||||
]);
|
||||
|
||||
return blob;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useExportGenerationAudio() {
|
||||
const platform = usePlatform();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||
const blob = await apiClient.exportGenerationAudio(generationId);
|
||||
|
||||
|
||||
// Create safe filename from text
|
||||
const safeText = text.substring(0, 30).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const safeText = text
|
||||
.substring(0, 30)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `${safeText}.wav`;
|
||||
|
||||
if (isTauri()) {
|
||||
// Use Tauri's native save dialog
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const filePath = await save({
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{
|
||||
name: 'Audio File',
|
||||
extensions: ['wav'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
// Write file using Tauri's filesystem API
|
||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
||||
// Fall back to browser download if Tauri dialog fails
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} else {
|
||||
// Browser: trigger download
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
name: 'Audio File',
|
||||
extensions: ['wav'],
|
||||
},
|
||||
]);
|
||||
|
||||
return blob;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import type { ModelProgress } from '@/lib/api/types';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
interface UseModelDownloadToastOptions {
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
enabled?: boolean;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,47 +21,64 @@ export function useModelDownloadToast({
|
||||
modelName,
|
||||
displayName,
|
||||
enabled = false,
|
||||
onComplete,
|
||||
onError,
|
||||
}: UseModelDownloadToastOptions) {
|
||||
const { toast } = useToast();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const toastIdRef = useRef<string | null>(null);
|
||||
const toastUpdateRef = useRef<
|
||||
((props: {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
duration?: number;
|
||||
variant?: 'default' | 'destructive';
|
||||
open?: boolean;
|
||||
}) => void) | null
|
||||
>(null);
|
||||
// biome-ignore lint: Using any for toast update ref to handle complex toast types
|
||||
const toastUpdateRef = useRef<any>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
const formatBytes = useCallback((bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[useModelDownloadToast] useEffect triggered', {
|
||||
enabled,
|
||||
serverUrl,
|
||||
modelName,
|
||||
displayName,
|
||||
});
|
||||
|
||||
if (!enabled || !serverUrl || !modelName) {
|
||||
console.log('[useModelDownloadToast] Not enabled, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[useModelDownloadToast] Creating toast and EventSource for:', modelName);
|
||||
|
||||
// Create initial toast
|
||||
const toastResult = toast({
|
||||
title: displayName,
|
||||
description: 'Starting download...',
|
||||
description: (
|
||||
<div className="flex items-center gap-2">
|
||||
<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
|
||||
});
|
||||
toastIdRef.current = toastResult.id;
|
||||
toastUpdateRef.current = toastResult.update;
|
||||
|
||||
// Subscribe to progress updates via Server-Sent Events
|
||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||
const eventSourceUrl = `${serverUrl}/models/progress/${modelName}`;
|
||||
console.log('[useModelDownloadToast] Creating EventSource to:', eventSourceUrl);
|
||||
const eventSource = new EventSource(eventSourceUrl);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('[useModelDownloadToast] EventSource connection opened for:', modelName);
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
console.log('[useModelDownloadToast] Received SSE message:', event.data);
|
||||
try {
|
||||
const progress = JSON.parse(event.data) as ModelProgress;
|
||||
|
||||
@@ -82,11 +101,11 @@ export function useModelDownloadToast({
|
||||
break;
|
||||
case 'error':
|
||||
statusIcon = <XCircle className="h-4 w-4 text-destructive" />;
|
||||
statusText = `Error: ${progress.error || 'Unknown error'}`;
|
||||
statusText = 'Download failed. See Problems panel for details.';
|
||||
break;
|
||||
case 'downloading':
|
||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||
statusText = progress.filename ? `Downloading ${progress.filename}...` : 'Downloading...';
|
||||
statusText = progress.filename || 'Downloading...';
|
||||
break;
|
||||
case 'extracting':
|
||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||
@@ -112,26 +131,44 @@ export function useModelDownloadToast({
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
duration: progress.status === 'complete' ? 5000 : Infinity,
|
||||
variant: progress.status === 'error' ? 'destructive' : 'default',
|
||||
duration: progress.status === 'complete' || progress.status === 'error' ? 5000 : Infinity,
|
||||
});
|
||||
|
||||
// Close connection and dismiss toast on completion or error
|
||||
if (progress.status === 'complete' || progress.status === 'error') {
|
||||
// Also treat progress >= 100% as complete
|
||||
const isComplete = progress.status === 'complete' || progress.progress >= 100;
|
||||
const isError = progress.status === 'error';
|
||||
|
||||
if (isComplete || isError) {
|
||||
console.log('[useModelDownloadToast] Download finished:', {
|
||||
isComplete,
|
||||
isError,
|
||||
progress: progress.progress,
|
||||
});
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
// Auto-dismiss on completion after delay
|
||||
if (progress.status === 'complete') {
|
||||
setTimeout(() => {
|
||||
if (toastIdRef.current && toastUpdateRef.current) {
|
||||
toastUpdateRef.current({
|
||||
open: false,
|
||||
});
|
||||
toastIdRef.current = null;
|
||||
toastUpdateRef.current = null;
|
||||
}
|
||||
}, 5000);
|
||||
// Update toast to show completion state before callbacks
|
||||
if (isComplete && toastUpdateRef.current) {
|
||||
toastUpdateRef.current({
|
||||
title: (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>{displayName}</span>
|
||||
</div>
|
||||
),
|
||||
description: 'Download complete',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
// Call callbacks
|
||||
if (isComplete && onComplete) {
|
||||
console.log('[useModelDownloadToast] Download complete, calling onComplete callback');
|
||||
onComplete();
|
||||
} else if (isError && onError) {
|
||||
console.log('[useModelDownloadToast] Download error, calling onError callback');
|
||||
onError(progress.error || 'Unknown error');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,8 +177,9 @@ export function useModelDownloadToast({
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
console.error('SSE error');
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('[useModelDownloadToast] SSE error for:', modelName, error);
|
||||
console.log('[useModelDownloadToast] EventSource readyState:', eventSource.readyState);
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
@@ -162,15 +200,16 @@ export function useModelDownloadToast({
|
||||
|
||||
// Cleanup on unmount or when disabled
|
||||
return () => {
|
||||
console.log('[useModelDownloadToast] Cleanup - closing EventSource for:', modelName);
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
// Note: We don't dismiss the toast here as it might still be showing completion state
|
||||
};
|
||||
}, [enabled, serverUrl, modelName, displayName, toast]);
|
||||
}, [enabled, serverUrl, modelName, displayName, toast, formatBytes, onComplete, onError]);
|
||||
|
||||
return {
|
||||
isTracking: enabled && eventSourceRef.current !== null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { VoiceProfileCreate } from '@/lib/api/types';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function useProfiles() {
|
||||
return useQuery({
|
||||
@@ -98,60 +98,43 @@ export function useDeleteSample() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSample() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ sampleId, referenceText }: { sampleId: string; referenceText: string }) =>
|
||||
apiClient.updateProfileSample(sampleId, referenceText),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['profiles', data.profile_id, 'samples'],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['profiles', data.profile_id],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useExportProfile() {
|
||||
const platform = usePlatform();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (profileId: string) => {
|
||||
const blob = await apiClient.exportProfile(profileId);
|
||||
|
||||
|
||||
// Get profile name for filename
|
||||
const profile = await apiClient.getProfile(profileId);
|
||||
const safeName = profile.name.replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const filename = `profile-${safeName}.voicebox.zip`;
|
||||
|
||||
if (isTauri()) {
|
||||
// Use Tauri's native save dialog
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const filePath = await save({
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{
|
||||
name: 'Voicebox Profile',
|
||||
extensions: ['voicebox.zip', 'zip'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
// Write file using Tauri's filesystem API
|
||||
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
||||
// Fall back to browser download if Tauri dialog fails
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} else {
|
||||
// Browser: trigger download
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
name: 'Voicebox Profile',
|
||||
extensions: ['zip'],
|
||||
},
|
||||
]);
|
||||
|
||||
return blob;
|
||||
},
|
||||
});
|
||||
@@ -167,3 +150,32 @@ export function useImportProfile() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadAvatar() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ profileId, file }: { profileId: string; file: File }) =>
|
||||
apiClient.uploadAvatar(profileId, file),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['profiles', variables.profileId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAvatar() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (profileId: string) => apiClient.deleteAvatar(profileId),
|
||||
onSuccess: (_, profileId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['profiles', profileId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import type { ActiveDownloadTask } from '@/lib/api/types';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
|
||||
// Polling interval in milliseconds
|
||||
const POLL_INTERVAL = 2000;
|
||||
const POLL_INTERVAL = 30000;
|
||||
|
||||
/**
|
||||
* Hook to monitor active tasks (downloads and generations).
|
||||
* Polls the server periodically to catch downloads triggered from anywhere
|
||||
* (transcription, generation, explicit download, etc.).
|
||||
*
|
||||
*
|
||||
* Returns the active downloads so components can render download toasts.
|
||||
*/
|
||||
export function useRestoreActiveTasks() {
|
||||
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
|
||||
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
||||
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
|
||||
|
||||
|
||||
// Track which downloads we've seen to detect new ones
|
||||
const seenDownloadsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
@@ -41,14 +41,14 @@ export function useRestoreActiveTasks() {
|
||||
// Update active downloads
|
||||
// Keep track of all active downloads (including new ones)
|
||||
const currentDownloadNames = new Set(tasks.downloads.map((d) => d.model_name));
|
||||
|
||||
|
||||
// Remove completed downloads from our seen set
|
||||
for (const name of seenDownloadsRef.current) {
|
||||
if (!currentDownloadNames.has(name)) {
|
||||
seenDownloadsRef.current.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add new downloads to seen set
|
||||
for (const download of tasks.downloads) {
|
||||
seenDownloadsRef.current.add(download.model_name);
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function useStories() {
|
||||
return useQuery({
|
||||
queryKey: ['stories'],
|
||||
queryFn: () => apiClient.listStories(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStory(storyId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['stories', storyId],
|
||||
queryFn: () => apiClient.getStory(storyId!),
|
||||
enabled: !!storyId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateStory() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: StoryCreate) => apiClient.createStory(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateStory() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, data }: { storyId: string; data: StoryCreate }) =>
|
||||
apiClient.updateStory(storyId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteStory() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (storyId: string) => apiClient.deleteStory(storyId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddStoryItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemCreate }) =>
|
||||
apiClient.addStoryItem(storyId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveStoryItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, itemId }: { storyId: string; itemId: string }) =>
|
||||
apiClient.removeStoryItem(storyId, itemId),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateStoryItemTimes() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemBatchUpdate }) =>
|
||||
apiClient.updateStoryItemTimes(storyId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderStoryItems() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemReorder }) =>
|
||||
apiClient.reorderStoryItems(storyId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMoveStoryItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) =>
|
||||
apiClient.moveStoryItem(storyId, itemId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTrimStoryItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemTrim }) =>
|
||||
apiClient.trimStoryItem(storyId, itemId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSplitStoryItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemSplit }) =>
|
||||
apiClient.splitStoryItem(storyId, itemId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDuplicateStoryItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, itemId }: { storyId: string; itemId: string }) =>
|
||||
apiClient.duplicateStoryItem(storyId, itemId),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useExportStoryAudio() {
|
||||
const platform = usePlatform();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ storyId, storyName }: { storyId: string; storyName: string }) => {
|
||||
const blob = await apiClient.exportStoryAudio(storyId);
|
||||
|
||||
// Create safe filename
|
||||
const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const filename = `${safeName || 'story'}.wav`;
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
name: 'Audio File',
|
||||
extensions: ['wav'],
|
||||
},
|
||||
]);
|
||||
|
||||
return blob;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { StoryItemDetail } from '@/lib/api/types';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
|
||||
interface ActiveSource {
|
||||
source: AudioBufferSourceNode;
|
||||
itemId: string;
|
||||
generationId: string;
|
||||
startTimeMs: number;
|
||||
endTimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing timecode-based story playback using Web Audio API.
|
||||
* Supports multiple simultaneous audio sources for overlapping clips on different tracks.
|
||||
* Uses AudioContext for sample-accurate timing synchronization.
|
||||
*/
|
||||
export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
||||
const isPlaying = useStoryStore((state) => state.isPlaying);
|
||||
const playbackItems = useStoryStore((state) => state.playbackItems);
|
||||
const playbackStartContextTime = useStoryStore((state) => state.playbackStartContextTime);
|
||||
const playbackStartStoryTime = useStoryStore((state) => state.playbackStartStoryTime);
|
||||
const setPlaybackTiming = useStoryStore((state) => state.setPlaybackTiming);
|
||||
|
||||
// AudioContext instance (created once)
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
// Master gain for volume control
|
||||
const masterGainRef = useRef<GainNode | null>(null);
|
||||
// Preloaded AudioBuffers by generation_id (audio file is shared between split clips)
|
||||
const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map());
|
||||
// Currently playing AudioBufferSourceNodes by item.id (unique per clip)
|
||||
const activeSourcesRef = useRef<Map<string, ActiveSource>>(new Map());
|
||||
// Animation frame for syncing visual playhead
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
|
||||
// Get or create AudioContext and audio graph
|
||||
const getAudioContext = useCallback(() => {
|
||||
if (!audioContextRef.current) {
|
||||
audioContextRef.current = new AudioContext();
|
||||
console.log(
|
||||
'[StoryPlayback] Created AudioContext, sample rate:',
|
||||
audioContextRef.current.sampleRate,
|
||||
);
|
||||
|
||||
// Create master gain node for volume control
|
||||
masterGainRef.current = audioContextRef.current.createGain();
|
||||
masterGainRef.current.gain.value = 1;
|
||||
masterGainRef.current.connect(audioContextRef.current.destination);
|
||||
}
|
||||
// Resume context if suspended (browser autoplay policy)
|
||||
if (audioContextRef.current.state === 'suspended') {
|
||||
audioContextRef.current.resume().catch(() => {
|
||||
// Ignore resume errors
|
||||
});
|
||||
}
|
||||
return audioContextRef.current;
|
||||
}, []);
|
||||
|
||||
// Stop a source by item id
|
||||
const stopSource = useCallback((itemId: string) => {
|
||||
const activeSource = activeSourcesRef.current.get(itemId);
|
||||
if (activeSource) {
|
||||
try {
|
||||
activeSource.source.stop();
|
||||
} catch {
|
||||
// Source may have already stopped
|
||||
}
|
||||
activeSourcesRef.current.delete(itemId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Preload audio files as AudioBuffers
|
||||
useEffect(() => {
|
||||
if (!items || items.length === 0) {
|
||||
// Clear preloaded buffers when no items
|
||||
audioBuffersRef.current.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIds = new Set(items.map((item) => item.generation_id));
|
||||
const audioContext = getAudioContext();
|
||||
|
||||
// Remove buffers for items that no longer exist
|
||||
for (const [id] of audioBuffersRef.current) {
|
||||
if (!currentIds.has(id)) {
|
||||
audioBuffersRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Preload audio for new items
|
||||
const preloadPromises: Promise<void>[] = [];
|
||||
for (const item of items) {
|
||||
if (!audioBuffersRef.current.has(item.generation_id)) {
|
||||
const audioUrl = apiClient.getAudioUrl(item.generation_id);
|
||||
console.log('[StoryPlayback] Preloading audio buffer:', item.generation_id);
|
||||
|
||||
const preloadPromise = fetch(audioUrl)
|
||||
.then((response) => response.arrayBuffer())
|
||||
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
|
||||
.then((audioBuffer) => {
|
||||
audioBuffersRef.current.set(item.generation_id, audioBuffer);
|
||||
console.log(
|
||||
'[StoryPlayback] Preloaded buffer:',
|
||||
item.generation_id,
|
||||
'duration:',
|
||||
audioBuffer.duration,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[StoryPlayback] Failed to preload audio:', item.generation_id, err);
|
||||
});
|
||||
|
||||
preloadPromises.push(preloadPromise);
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all(preloadPromises).then(() => {
|
||||
console.log('[StoryPlayback] Preloaded', audioBuffersRef.current.size, 'audio buffers');
|
||||
});
|
||||
}, [items, getAudioContext]);
|
||||
|
||||
// Cleanup AudioContext on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Stop all sources
|
||||
for (const [itemId] of activeSourcesRef.current) {
|
||||
stopSource(itemId);
|
||||
}
|
||||
activeSourcesRef.current.clear();
|
||||
|
||||
// Clean up audio graph
|
||||
if (masterGainRef.current) {
|
||||
masterGainRef.current.disconnect();
|
||||
masterGainRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
||||
audioContextRef.current.close().catch(() => {
|
||||
// Ignore errors when closing
|
||||
});
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [stopSource]);
|
||||
|
||||
// Find ALL items that should be playing at a given story time
|
||||
const findActiveItems = useCallback(
|
||||
(storyTimeMs: number, itemList: StoryItemDetail[]): StoryItemDetail[] => {
|
||||
return itemList.filter((item) => {
|
||||
const itemStart = item.start_time_ms;
|
||||
// Use effective duration (accounting for trims)
|
||||
const trimStartMs = item.trim_start_ms || 0;
|
||||
const trimEndMs = item.trim_end_ms || 0;
|
||||
const effectiveDurationMs = item.duration * 1000 - trimStartMs - trimEndMs;
|
||||
const itemEnd = item.start_time_ms + effectiveDurationMs;
|
||||
return storyTimeMs >= itemStart && storyTimeMs < itemEnd;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Convert AudioContext time to story time (ms)
|
||||
const contextTimeToStoryTime = useCallback(
|
||||
(contextTime: number): number => {
|
||||
if (playbackStartContextTime === null || playbackStartStoryTime === null) {
|
||||
return 0;
|
||||
}
|
||||
const elapsedContextTime = contextTime - playbackStartContextTime;
|
||||
return playbackStartStoryTime + elapsedContextTime * 1000;
|
||||
},
|
||||
[playbackStartContextTime, playbackStartStoryTime],
|
||||
);
|
||||
|
||||
// Convert story time (ms) to AudioContext time
|
||||
const storyTimeToContextTime = useCallback(
|
||||
(storyTimeMs: number): number => {
|
||||
if (playbackStartContextTime === null || playbackStartStoryTime === null) {
|
||||
return 0;
|
||||
}
|
||||
const elapsedStoryTime = (storyTimeMs - playbackStartStoryTime) / 1000;
|
||||
return playbackStartContextTime + elapsedStoryTime;
|
||||
},
|
||||
[playbackStartContextTime, playbackStartStoryTime],
|
||||
);
|
||||
|
||||
// Stop all sources
|
||||
const stopAllSources = useCallback(() => {
|
||||
console.log('[StoryPlayback] Stopping all sources');
|
||||
for (const [itemId] of activeSourcesRef.current) {
|
||||
stopSource(itemId);
|
||||
}
|
||||
activeSourcesRef.current.clear();
|
||||
}, [stopSource]);
|
||||
|
||||
// Schedule playback for all items that should be playing
|
||||
const schedulePlayback = useCallback(
|
||||
(storyTimeMs: number, itemList: StoryItemDetail[]) => {
|
||||
const audioContext = getAudioContext();
|
||||
const currentContextTime = audioContext.currentTime;
|
||||
|
||||
// Find all items that should be playing
|
||||
const shouldBePlaying = findActiveItems(storyTimeMs, itemList);
|
||||
const shouldBePlayingIds = new Set(shouldBePlaying.map((item) => item.id));
|
||||
|
||||
// Stop sources that shouldn't be playing anymore
|
||||
for (const [itemId] of activeSourcesRef.current) {
|
||||
if (!shouldBePlayingIds.has(itemId)) {
|
||||
stopSource(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule new sources for items that should be playing
|
||||
for (const item of shouldBePlaying) {
|
||||
if (!activeSourcesRef.current.has(item.id)) {
|
||||
const buffer = audioBuffersRef.current.get(item.generation_id);
|
||||
if (!buffer) {
|
||||
console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate when this item should start in AudioContext time
|
||||
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
|
||||
|
||||
// Calculate effective duration and trim offsets
|
||||
const trimStartSec = (item.trim_start_ms || 0) / 1000;
|
||||
const trimEndSec = (item.trim_end_ms || 0) / 1000;
|
||||
const effectiveDuration = item.duration - trimStartSec - trimEndSec;
|
||||
const itemEndStoryTime = item.start_time_ms + effectiveDuration * 1000;
|
||||
|
||||
// Calculate offset into the buffer (if seeking mid-way)
|
||||
// Offset is relative to the trimmed start of the clip
|
||||
const offsetIntoEffectiveClip = Math.max(0, (storyTimeMs - item.start_time_ms) / 1000);
|
||||
const offsetIntoBuffer = trimStartSec + offsetIntoEffectiveClip;
|
||||
const duration = effectiveDuration - offsetIntoEffectiveClip;
|
||||
|
||||
// If the item should have already started, schedule it to start immediately
|
||||
const startAtContextTime = Math.max(currentContextTime, itemStartContextTime);
|
||||
|
||||
console.log('[StoryPlayback] Scheduling source:', {
|
||||
itemId: item.id,
|
||||
generationId: item.generation_id,
|
||||
storyTimeMs,
|
||||
itemStart: item.start_time_ms,
|
||||
offsetIntoBuffer,
|
||||
startAtContextTime,
|
||||
duration,
|
||||
});
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(masterGainRef.current || audioContext.destination);
|
||||
|
||||
const activeSource: ActiveSource = {
|
||||
source,
|
||||
itemId: item.id,
|
||||
generationId: item.generation_id,
|
||||
startTimeMs: item.start_time_ms,
|
||||
endTimeMs: itemEndStoryTime,
|
||||
};
|
||||
|
||||
activeSourcesRef.current.set(item.id, activeSource);
|
||||
|
||||
// Schedule playback
|
||||
source.start(startAtContextTime, offsetIntoBuffer, duration);
|
||||
|
||||
// Clean up when source ends
|
||||
source.onended = () => {
|
||||
console.log('[StoryPlayback] Source ended:', item.id);
|
||||
activeSourcesRef.current.delete(item.id);
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
[getAudioContext, findActiveItems, storyTimeToContextTime, stopSource],
|
||||
);
|
||||
|
||||
// Sync visual playhead from AudioContext time
|
||||
useEffect(() => {
|
||||
if (!isPlaying || playbackStartContextTime === null || playbackStartStoryTime === null) {
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const audioContext = getAudioContext();
|
||||
const itemList = playbackItems || [];
|
||||
|
||||
const syncPlayhead = () => {
|
||||
if (!useStoryStore.getState().isPlaying) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContextTime = audioContext.currentTime;
|
||||
const currentStoryTime = contextTimeToStoryTime(currentContextTime);
|
||||
const totalDuration = useStoryStore.getState().totalDurationMs;
|
||||
|
||||
// Update store with current story time
|
||||
useStoryStore.setState({ currentTimeMs: Math.min(currentStoryTime, totalDuration) });
|
||||
|
||||
// Schedule any items that should be playing
|
||||
schedulePlayback(currentStoryTime, itemList);
|
||||
|
||||
// Check if we've reached the end
|
||||
if (currentStoryTime >= totalDuration) {
|
||||
// Check if all sources have ended
|
||||
if (activeSourcesRef.current.size === 0) {
|
||||
console.log('[StoryPlayback] Reached end');
|
||||
useStoryStore.getState().stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Continue sync loop
|
||||
animationFrameRef.current = requestAnimationFrame(syncPlayhead);
|
||||
};
|
||||
|
||||
// Initial sync
|
||||
const currentContextTime = audioContext.currentTime;
|
||||
const currentStoryTime = contextTimeToStoryTime(currentContextTime);
|
||||
schedulePlayback(currentStoryTime, itemList);
|
||||
|
||||
// Start sync loop
|
||||
animationFrameRef.current = requestAnimationFrame(syncPlayhead);
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
isPlaying,
|
||||
playbackItems,
|
||||
playbackStartContextTime,
|
||||
playbackStartStoryTime,
|
||||
getAudioContext,
|
||||
contextTimeToStoryTime,
|
||||
schedulePlayback,
|
||||
]);
|
||||
|
||||
// Handle play/pause changes - stop sources when paused
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
console.log('[StoryPlayback] Stopping playback');
|
||||
stopAllSources();
|
||||
}
|
||||
}, [isPlaying, stopAllSources]);
|
||||
|
||||
// Handle seek - reset timing anchors when they become null (triggered by seek)
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !playbackItems || playbackItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only run when timing anchors are null (after a seek)
|
||||
if (playbackStartContextTime !== null && playbackStartStoryTime !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audioContext = getAudioContext();
|
||||
const currentContextTime = audioContext.currentTime;
|
||||
const currentStoryTime = useStoryStore.getState().currentTimeMs;
|
||||
|
||||
console.log('[StoryPlayback] Setting timing anchors after seek:', {
|
||||
contextTime: currentContextTime,
|
||||
storyTime: currentStoryTime,
|
||||
});
|
||||
setPlaybackTiming(currentContextTime, currentStoryTime);
|
||||
|
||||
// Stop all existing sources and reschedule from new position
|
||||
stopAllSources();
|
||||
schedulePlayback(currentStoryTime, playbackItems);
|
||||
}, [
|
||||
isPlaying,
|
||||
playbackItems,
|
||||
playbackStartContextTime,
|
||||
playbackStartStoryTime,
|
||||
getAudioContext,
|
||||
stopAllSources,
|
||||
schedulePlayback,
|
||||
setPlaybackTiming,
|
||||
]);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
interface UseSystemAudioCaptureOptions {
|
||||
maxDurationSeconds?: number;
|
||||
@@ -12,9 +11,10 @@ interface UseSystemAudioCaptureOptions {
|
||||
* Uses ScreenCaptureKit on macOS and WASAPI loopback on Windows.
|
||||
*/
|
||||
export function useSystemAudioCapture({
|
||||
maxDurationSeconds = 30,
|
||||
maxDurationSeconds = 29,
|
||||
onRecordingComplete,
|
||||
}: UseSystemAudioCaptureOptions = {}) {
|
||||
const platform = usePlatform();
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -22,25 +22,16 @@ export function useSystemAudioCapture({
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
const stopRecordingRef = useRef<(() => Promise<void>) | null>(null);
|
||||
const isRecordingRef = useRef(false);
|
||||
|
||||
// Check if system audio capture is supported
|
||||
useEffect(() => {
|
||||
if (!isTauri()) {
|
||||
setIsSupported(false);
|
||||
return;
|
||||
}
|
||||
|
||||
invoke<boolean>('is_system_audio_supported')
|
||||
.then((supported) => {
|
||||
setIsSupported(supported);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsSupported(false);
|
||||
});
|
||||
}, []);
|
||||
const supported = platform.audio.isSystemAudioSupported();
|
||||
setIsSupported(supported);
|
||||
}, [platform]);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!isTauri()) {
|
||||
if (!platform.metadata.isTauri) {
|
||||
const errorMsg = 'System audio capture is only available in the desktop app.';
|
||||
setError(errorMsg);
|
||||
return;
|
||||
@@ -57,11 +48,10 @@ export function useSystemAudioCapture({
|
||||
setDuration(0);
|
||||
|
||||
// Start native capture
|
||||
await invoke('start_system_audio_capture', {
|
||||
maxDurationSecs: maxDurationSeconds,
|
||||
});
|
||||
await platform.audio.startSystemAudioCapture(maxDurationSeconds);
|
||||
|
||||
setIsRecording(true);
|
||||
isRecordingRef.current = true;
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
// Start timer
|
||||
@@ -84,32 +74,25 @@ export function useSystemAudioCapture({
|
||||
setError(errorMessage);
|
||||
setIsRecording(false);
|
||||
}
|
||||
}, [maxDurationSeconds, isSupported]);
|
||||
}, [maxDurationSeconds, isSupported, platform]);
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
if (!isRecording || !isTauri()) {
|
||||
if (!isRecording || !platform.metadata.isTauri) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsRecording(false);
|
||||
isRecordingRef.current = false;
|
||||
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// Stop capture and get base64 WAV data
|
||||
const base64Data = await invoke<string>('stop_system_audio_capture');
|
||||
// Stop capture and get Blob
|
||||
const blob = await platform.audio.stopSystemAudioCapture();
|
||||
|
||||
// Convert base64 to Blob
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([bytes], { type: 'audio/wav' });
|
||||
// Pass the actual recorded duration
|
||||
const recordedDuration = startTimeRef.current
|
||||
? (Date.now() - startTimeRef.current) / 1000
|
||||
@@ -122,7 +105,7 @@ export function useSystemAudioCapture({
|
||||
: 'Failed to stop system audio capture.';
|
||||
setError(errorMessage);
|
||||
}
|
||||
}, [isRecording, onRecordingComplete]);
|
||||
}, [isRecording, onRecordingComplete, platform]);
|
||||
|
||||
// Store stopRecording in ref for use in timer
|
||||
useEffect(() => {
|
||||
@@ -130,32 +113,37 @@ export function useSystemAudioCapture({
|
||||
}, [stopRecording]);
|
||||
|
||||
const cancelRecording = useCallback(async () => {
|
||||
if (isRecording) {
|
||||
if (isRecordingRef.current) {
|
||||
await stopRecording();
|
||||
}
|
||||
|
||||
setIsRecording(false);
|
||||
isRecordingRef.current = false;
|
||||
setDuration(0);
|
||||
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, [isRecording, stopRecording]);
|
||||
}, [stopRecording]);
|
||||
|
||||
// Cleanup on unmount
|
||||
// Cleanup on unmount only
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
// Cancel recording on unmount if still recording
|
||||
if (isRecording) {
|
||||
void cancelRecording();
|
||||
if (isRecordingRef.current && platform.metadata.isTauri) {
|
||||
// Call stop directly without the callback to avoid stale closure
|
||||
platform.audio.stopSystemAudioCapture().catch((err) => {
|
||||
console.error('Error stopping audio capture on unmount:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: cancelRecording is stable
|
||||
}, [isRecording]);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on unmount
|
||||
}, [platform]);
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { LanguageCode } from '@/lib/constants/languages';
|
||||
|
||||
export function useTranscription() {
|
||||
return useMutation({
|
||||
mutationFn: ({ file, language }: { file: File; language?: 'en' | 'zh' }) =>
|
||||
mutationFn: ({ file, language }: { file: File; language?: LanguageCode }) =>
|
||||
apiClient.transcribeAudio(file, language),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Tauri integration utilities
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, emit } from '@tauri-apps/api/event';
|
||||
|
||||
/**
|
||||
* Check if running in Tauri environment
|
||||
*/
|
||||
export function isTauri(): boolean {
|
||||
return '__TAURI_INTERNALS__' in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running on macOS
|
||||
*/
|
||||
export function isMacOS(): boolean {
|
||||
return navigator.platform.toLowerCase().includes('mac');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the bundled Python server (Tauri only)
|
||||
*/
|
||||
export async function startServer(remote = false): Promise<string> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri environment');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invoke<string>('start_server', { remote });
|
||||
console.log('Server started:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the bundled Python server (Tauri only)
|
||||
*/
|
||||
export async function stopServer(): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri environment');
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('stop_server');
|
||||
console.log('Server stopped');
|
||||
} catch (error) {
|
||||
console.error('Failed to stop server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the server should keep running when the app closes (Tauri only)
|
||||
*/
|
||||
export async function setKeepServerRunning(keepRunning: boolean): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('set_keep_server_running', { keepRunning });
|
||||
} catch (error) {
|
||||
console.error('Failed to set keep server running setting:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup window close handler to check setting and stop server if needed
|
||||
*/
|
||||
export async function setupWindowCloseHandler(): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Listen for window close request from Rust
|
||||
await listen<null>('window-close-requested', async () => {
|
||||
// Import store here to avoid circular dependency
|
||||
const { useServerStore } = await import('@/stores/serverStore');
|
||||
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
||||
|
||||
// Check if server was started by this app instance
|
||||
// In dev mode, serverStartedByApp will be false, so we won't try to stop a separately-run server
|
||||
// We need to access the module-level variable - this is a bit hacky but works
|
||||
// @ts-expect-error - accessing module-level variable from another module
|
||||
const serverStartedByApp = window.__voiceboxServerStartedByApp ?? false;
|
||||
|
||||
if (!keepRunning && serverStartedByApp) {
|
||||
// Stop server before closing (only if we started it)
|
||||
try {
|
||||
await stopServer();
|
||||
} catch (error) {
|
||||
console.error('Failed to stop server on close:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit event back to Rust to allow close
|
||||
await emit('window-close-allowed');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to setup window close handler:', error);
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,157 @@ export function formatAudioDuration(seconds: number): string {
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audio duration from a File.
|
||||
* If the file has a recordedDuration property (from recording hooks),
|
||||
* use that instead of trying to read metadata. This fixes issues on Windows
|
||||
* where WebM files from MediaRecorder don't have proper duration metadata.
|
||||
*
|
||||
* For uploaded files we use AudioContext.decodeAudioData which fully decodes
|
||||
* the audio and returns the exact duration. This is more reliable than
|
||||
* HTMLMediaElement.duration which can return incorrect large values for VBR
|
||||
* MP3 files that lack a proper XING/VBRI header.
|
||||
*/
|
||||
export async function getAudioDuration(
|
||||
file: File & { recordedDuration?: number },
|
||||
): Promise<number> {
|
||||
if (file.recordedDuration !== undefined && Number.isFinite(file.recordedDuration)) {
|
||||
return file.recordedDuration;
|
||||
}
|
||||
|
||||
// Use Web Audio API for accurate duration — avoids VBR MP3 metadata issues.
|
||||
try {
|
||||
const audioContext = new AudioContext();
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
return audioBuffer.duration;
|
||||
} finally {
|
||||
await audioContext.close();
|
||||
}
|
||||
} catch {
|
||||
// Fallback: read duration from the media element (less accurate but works for WAV).
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
||||
resolve(audio.duration);
|
||||
} else {
|
||||
reject(new Error('Audio file has invalid duration metadata'));
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load audio file'));
|
||||
});
|
||||
|
||||
audio.src = url;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any audio blob to WAV format using Web Audio API.
|
||||
* This ensures compatibility without requiring ffmpeg on the backend.
|
||||
*/
|
||||
export async function convertToWav(audioBlob: Blob): Promise<Blob> {
|
||||
// Create audio context
|
||||
const audioContext = new AudioContext();
|
||||
|
||||
// Read blob as array buffer
|
||||
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||
|
||||
// Decode audio data
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
|
||||
// Convert to WAV
|
||||
const wavBlob = audioBufferToWav(audioBuffer);
|
||||
|
||||
// Close audio context to free resources
|
||||
await audioContext.close();
|
||||
|
||||
return wavBlob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert AudioBuffer to WAV blob.
|
||||
*/
|
||||
function audioBufferToWav(buffer: AudioBuffer): Blob {
|
||||
const numberOfChannels = buffer.numberOfChannels;
|
||||
const sampleRate = buffer.sampleRate;
|
||||
const format = 1; // PCM
|
||||
const bitDepth = 16;
|
||||
|
||||
const bytesPerSample = bitDepth / 8;
|
||||
const blockAlign = numberOfChannels * bytesPerSample;
|
||||
|
||||
// Interleave channels
|
||||
const interleaved = interleaveChannels(buffer);
|
||||
|
||||
// Create WAV file
|
||||
const dataLength = interleaved.length * bytesPerSample;
|
||||
const buffer2 = new ArrayBuffer(44 + dataLength);
|
||||
const view = new DataView(buffer2);
|
||||
|
||||
// Write WAV header
|
||||
writeString(view, 0, 'RIFF');
|
||||
view.setUint32(4, 36 + dataLength, true);
|
||||
writeString(view, 8, 'WAVE');
|
||||
writeString(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true); // fmt chunk size
|
||||
view.setUint16(20, format, true); // audio format (PCM)
|
||||
view.setUint16(22, numberOfChannels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, bitDepth, true);
|
||||
writeString(view, 36, 'data');
|
||||
view.setUint32(40, dataLength, true);
|
||||
|
||||
// Write audio data
|
||||
floatTo16BitPCM(view, 44, interleaved);
|
||||
|
||||
return new Blob([buffer2], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Interleave multiple channels into a single array.
|
||||
*/
|
||||
function interleaveChannels(buffer: AudioBuffer): Float32Array {
|
||||
const numberOfChannels = buffer.numberOfChannels;
|
||||
const length = buffer.length;
|
||||
const interleaved = new Float32Array(length * numberOfChannels);
|
||||
|
||||
for (let channel = 0; channel < numberOfChannels; channel++) {
|
||||
const channelData = buffer.getChannelData(channel);
|
||||
for (let i = 0; i < length; i++) {
|
||||
interleaved[i * numberOfChannels + channel] = channelData[i];
|
||||
}
|
||||
}
|
||||
|
||||
return interleaved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write string to DataView.
|
||||
*/
|
||||
function writeString(view: DataView, offset: number, string: string): void {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert float32 audio data to 16-bit PCM.
|
||||
*/
|
||||
function floatTo16BitPCM(view: DataView, offset: number, input: Float32Array): void {
|
||||
for (let i = 0; i < input.length; i++, offset += 2) {
|
||||
const s = Math.max(-1, Math.min(1, input[i]));
|
||||
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const DEBUG = import.meta.env.DEV;
|
||||
|
||||
export const debug = {
|
||||
log: (...args: unknown[]) => {
|
||||
if (DEBUG) {
|
||||
console.log(...args);
|
||||
}
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
if (DEBUG) {
|
||||
console.error(...args);
|
||||
}
|
||||
},
|
||||
warn: (...args: unknown[]) => {
|
||||
if (DEBUG) {
|
||||
console.warn(...args);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import type { Platform } from './types';
|
||||
|
||||
const PlatformContext = createContext<Platform | null>(null);
|
||||
|
||||
export interface PlatformProviderProps {
|
||||
platform: Platform;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PlatformProvider({ platform, children }: PlatformProviderProps) {
|
||||
return (
|
||||
<PlatformContext.Provider value={platform}>
|
||||
{children}
|
||||
</PlatformContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePlatform(): Platform {
|
||||
const platform = useContext(PlatformContext);
|
||||
if (!platform) {
|
||||
throw new Error('usePlatform must be used within PlatformProvider');
|
||||
}
|
||||
return platform;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Platform abstraction types
|
||||
* These interfaces define the contract that platform implementations must fulfill
|
||||
*/
|
||||
|
||||
export interface FileFilter {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
export interface PlatformFilesystem {
|
||||
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
|
||||
}
|
||||
|
||||
export interface UpdateStatus {
|
||||
checking: boolean;
|
||||
available: boolean;
|
||||
version?: string;
|
||||
downloading: boolean;
|
||||
installing: boolean;
|
||||
readyToInstall: boolean;
|
||||
error?: string;
|
||||
downloadProgress?: number; // 0-100 percentage
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
}
|
||||
|
||||
export interface PlatformUpdater {
|
||||
checkForUpdates(): Promise<void>;
|
||||
downloadAndInstall(): Promise<void>;
|
||||
restartAndInstall(): Promise<void>;
|
||||
getStatus(): UpdateStatus;
|
||||
subscribe(callback: (status: UpdateStatus) => void): () => void;
|
||||
}
|
||||
|
||||
export interface AudioDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformAudio {
|
||||
isSystemAudioSupported(): boolean;
|
||||
startSystemAudioCapture(maxDurationSecs: number): Promise<void>;
|
||||
stopSystemAudioCapture(): Promise<Blob>;
|
||||
listOutputDevices(): Promise<AudioDevice[]>;
|
||||
playToDevices(audioData: Uint8Array, deviceIds: string[]): Promise<void>;
|
||||
stopPlayback(): void;
|
||||
}
|
||||
|
||||
export interface PlatformLifecycle {
|
||||
startServer(remote?: boolean, modelsDir?: string | null): Promise<string>;
|
||||
stopServer(): Promise<void>;
|
||||
restartServer(modelsDir?: string | null): Promise<string>;
|
||||
setKeepServerRunning(keep: boolean): Promise<void>;
|
||||
setupWindowCloseHandler(): Promise<void>;
|
||||
onServerReady?: () => void;
|
||||
}
|
||||
|
||||
export interface PlatformMetadata {
|
||||
getVersion(): Promise<string>;
|
||||
isTauri: boolean;
|
||||
}
|
||||
|
||||
export interface Platform {
|
||||
filesystem: PlatformFilesystem;
|
||||
updater: PlatformUpdater;
|
||||
audio: PlatformAudio;
|
||||
lifecycle: PlatformLifecycle;
|
||||
metadata: PlatformMetadata;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
|
||||
import { AppFrame } from '@/components/AppFrame/AppFrame';
|
||||
import { AudioTab } from '@/components/AudioTab/AudioTab';
|
||||
import { MainEditor } from '@/components/MainEditor/MainEditor';
|
||||
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
|
||||
import { ServerTab } from '@/components/ServerTab/ServerTab';
|
||||
import { Sidebar } from '@/components/Sidebar';
|
||||
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
||||
// Simple platform check that works in both web and Tauri
|
||||
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
||||
|
||||
// Root layout component
|
||||
function RootLayout() {
|
||||
// Monitor active downloads/generations and show toasts for them
|
||||
const activeDownloads = useRestoreActiveTasks();
|
||||
|
||||
return (
|
||||
<AppFrame>
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<Sidebar isMacOS={isMacOS()} />
|
||||
|
||||
<main className="flex-1 ml-20 overflow-hidden flex flex-col">
|
||||
<div className="container mx-auto px-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Show download toasts for any active downloads (from anywhere) */}
|
||||
{activeDownloads.map((download) => {
|
||||
const displayName = MODEL_DISPLAY_NAMES[download.model_name] || download.model_name;
|
||||
return (
|
||||
<DownloadToastRestorer
|
||||
key={download.model_name}
|
||||
modelName={download.model_name}
|
||||
displayName={displayName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Toaster />
|
||||
</AppFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that restores a download toast for a specific model.
|
||||
*/
|
||||
function DownloadToastRestorer({
|
||||
modelName,
|
||||
displayName,
|
||||
}: {
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
}) {
|
||||
// Use the download toast hook to restore the toast
|
||||
useModelDownloadToast({
|
||||
modelName,
|
||||
displayName,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Root route with layout
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
// Index route (main/generate)
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: MainEditor,
|
||||
});
|
||||
|
||||
// Stories route
|
||||
const storiesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/stories',
|
||||
component: StoriesTab,
|
||||
});
|
||||
|
||||
// Voices route
|
||||
const voicesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/voices',
|
||||
component: VoicesTab,
|
||||
});
|
||||
|
||||
// Audio route
|
||||
const audioRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/audio',
|
||||
component: AudioTab,
|
||||
});
|
||||
|
||||
// Models route
|
||||
const modelsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/models',
|
||||
component: ModelsTab,
|
||||
});
|
||||
|
||||
// Server route
|
||||
const serverRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/server',
|
||||
component: ServerTab,
|
||||
});
|
||||
|
||||
// Route tree
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
storiesRoute,
|
||||
voicesRoute,
|
||||
audioRoute,
|
||||
modelsRoute,
|
||||
serverRoute,
|
||||
]);
|
||||
|
||||
// Create router
|
||||
export const router = createRouter({ routeTree });
|
||||
|
||||
// Register router for type safety
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface AudioChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
device_ids: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AudioChannelStore {
|
||||
channels: AudioChannel[];
|
||||
setChannels: (channels: AudioChannel[]) => void;
|
||||
addChannel: (channel: AudioChannel) => void;
|
||||
updateChannel: (id: string, channel: Partial<AudioChannel>) => void;
|
||||
removeChannel: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useAudioChannelStore = create<AudioChannelStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
channels: [],
|
||||
setChannels: (channels) => set({ channels }),
|
||||
addChannel: (channel) =>
|
||||
set((state) => ({
|
||||
channels: [...state.channels, channel],
|
||||
})),
|
||||
updateChannel: (id, updates) =>
|
||||
set((state) => ({
|
||||
channels: state.channels.map((ch) => (ch.id === id ? { ...ch, ...updates } : ch)),
|
||||
})),
|
||||
removeChannel: (id) =>
|
||||
set((state) => ({
|
||||
channels: state.channels.filter((ch) => ch.id !== id),
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'voicebox-audio-channels',
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -3,6 +3,7 @@ import { create } from 'zustand';
|
||||
interface PlayerState {
|
||||
audioUrl: string | null;
|
||||
audioId: string | null;
|
||||
profileId: string | null;
|
||||
title: string | null;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
@@ -10,8 +11,11 @@ interface PlayerState {
|
||||
volume: number;
|
||||
isLooping: boolean;
|
||||
shouldRestart: boolean;
|
||||
shouldAutoPlay: boolean;
|
||||
onFinish: (() => void) | null;
|
||||
|
||||
setAudio: (url: string, id: string, title?: string) => void;
|
||||
setAudio: (url: string, id: string, profileId: string | null, title?: string) => void;
|
||||
setAudioWithAutoPlay: (url: string, id: string, profileId: string | null, title?: string) => void;
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
@@ -19,12 +23,15 @@ interface PlayerState {
|
||||
toggleLoop: () => void;
|
||||
restartCurrentAudio: () => void;
|
||||
clearRestartFlag: () => void;
|
||||
clearAutoPlayFlag: () => void;
|
||||
setOnFinish: (callback: (() => void) | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
audioUrl: null,
|
||||
audioId: null,
|
||||
profileId: null,
|
||||
title: null,
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
@@ -32,15 +39,30 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
volume: 1,
|
||||
isLooping: false,
|
||||
shouldRestart: false,
|
||||
shouldAutoPlay: false,
|
||||
onFinish: null,
|
||||
|
||||
setAudio: (url, id, title) =>
|
||||
setAudio: (url, id, profileId, title) =>
|
||||
set({
|
||||
audioUrl: url,
|
||||
audioId: id,
|
||||
profileId: profileId || null,
|
||||
title: title || null,
|
||||
currentTime: 0,
|
||||
isPlaying: false,
|
||||
shouldRestart: false,
|
||||
shouldAutoPlay: false,
|
||||
}),
|
||||
setAudioWithAutoPlay: (url, id, profileId, title) =>
|
||||
set({
|
||||
audioUrl: url,
|
||||
audioId: id,
|
||||
profileId: profileId || null,
|
||||
title: title || null,
|
||||
currentTime: 0,
|
||||
isPlaying: false,
|
||||
shouldRestart: false,
|
||||
shouldAutoPlay: true,
|
||||
}),
|
||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||
setCurrentTime: (time) => set({ currentTime: time }),
|
||||
@@ -49,15 +71,20 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
toggleLoop: () => set((state) => ({ isLooping: !state.isLooping })),
|
||||
restartCurrentAudio: () => set({ shouldRestart: true }),
|
||||
clearRestartFlag: () => set({ shouldRestart: false }),
|
||||
clearAutoPlayFlag: () => set({ shouldAutoPlay: false }),
|
||||
setOnFinish: (callback) => set({ onFinish: callback }),
|
||||
reset: () =>
|
||||
set({
|
||||
audioUrl: null,
|
||||
audioId: null,
|
||||
profileId: null,
|
||||
title: null,
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
isLooping: false,
|
||||
shouldRestart: false,
|
||||
shouldAutoPlay: false,
|
||||
onFinish: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -13,6 +13,18 @@ interface ServerStore {
|
||||
|
||||
keepServerRunningOnClose: boolean;
|
||||
setKeepServerRunningOnClose: (keepRunning: boolean) => void;
|
||||
|
||||
maxChunkChars: number;
|
||||
setMaxChunkChars: (value: number) => void;
|
||||
|
||||
crossfadeMs: number;
|
||||
setCrossfadeMs: (value: number) => void;
|
||||
|
||||
normalizeAudio: boolean;
|
||||
setNormalizeAudio: (value: boolean) => void;
|
||||
|
||||
customModelsDir: string | null;
|
||||
setCustomModelsDir: (dir: string | null) => void;
|
||||
}
|
||||
|
||||
export const useServerStore = create<ServerStore>()(
|
||||
@@ -29,6 +41,18 @@ export const useServerStore = create<ServerStore>()(
|
||||
|
||||
keepServerRunningOnClose: false,
|
||||
setKeepServerRunningOnClose: (keepRunning) => set({ keepServerRunningOnClose: keepRunning }),
|
||||
|
||||
maxChunkChars: 800,
|
||||
setMaxChunkChars: (value) => set({ maxChunkChars: value }),
|
||||
|
||||
crossfadeMs: 50,
|
||||
setCrossfadeMs: (value) => set({ crossfadeMs: value }),
|
||||
|
||||
normalizeAudio: true,
|
||||
setNormalizeAudio: (value) => set({ normalizeAudio: value }),
|
||||
|
||||
customModelsDir: null,
|
||||
setCustomModelsDir: (dir) => set({ customModelsDir: dir }),
|
||||
}),
|
||||
{
|
||||
name: 'voicebox-server',
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { create } from 'zustand';
|
||||
import type { StoryItemDetail } from '@/lib/api/types';
|
||||
|
||||
interface StoryPlaybackState {
|
||||
// Selection
|
||||
selectedStoryId: string | null;
|
||||
setSelectedStoryId: (id: string | null) => void;
|
||||
selectedClipId: string | null;
|
||||
setSelectedClipId: (id: string | null) => void;
|
||||
|
||||
// Track editor UI state
|
||||
trackEditorHeight: number;
|
||||
setTrackEditorHeight: (height: number) => void;
|
||||
|
||||
// Playback state
|
||||
isPlaying: boolean;
|
||||
currentTimeMs: number;
|
||||
totalDurationMs: number;
|
||||
playbackStoryId: string | null;
|
||||
playbackItems: StoryItemDetail[] | null;
|
||||
// Web Audio API timing (null when not playing)
|
||||
playbackStartContextTime: number | null; // AudioContext.currentTime when playback started
|
||||
playbackStartStoryTime: number | null; // Story time (ms) when playback started
|
||||
|
||||
// Actions
|
||||
play: (storyId: string, items: StoryItemDetail[]) => void;
|
||||
pause: () => void;
|
||||
stop: () => void;
|
||||
seek: (timeMs: number) => void;
|
||||
setPlaybackTiming: (contextTime: number, storyTime: number) => void; // Set timing anchors for Web Audio API
|
||||
setActiveStory: (storyId: string, items: StoryItemDetail[], totalDurationMs: number) => void; // Activate story for seeking without playing
|
||||
}
|
||||
|
||||
const DEFAULT_TRACK_EDITOR_HEIGHT = 250;
|
||||
|
||||
export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
||||
// Selection
|
||||
selectedStoryId: null,
|
||||
setSelectedStoryId: (id) => set({ selectedStoryId: id }),
|
||||
selectedClipId: null,
|
||||
setSelectedClipId: (id) => set({ selectedClipId: id }),
|
||||
|
||||
// Track editor UI state
|
||||
trackEditorHeight: DEFAULT_TRACK_EDITOR_HEIGHT,
|
||||
setTrackEditorHeight: (height) => set({ trackEditorHeight: height }),
|
||||
|
||||
// Playback state
|
||||
isPlaying: false,
|
||||
currentTimeMs: 0,
|
||||
totalDurationMs: 0,
|
||||
playbackStoryId: null,
|
||||
playbackItems: null,
|
||||
playbackStartContextTime: null,
|
||||
playbackStartStoryTime: null,
|
||||
|
||||
// Actions
|
||||
play: (storyId, items) => {
|
||||
// Calculate total duration from items
|
||||
const maxEndTimeMs = Math.max(
|
||||
...items.map((item) => item.start_time_ms + item.duration * 1000),
|
||||
0,
|
||||
);
|
||||
|
||||
// Find the minimum start time (first item)
|
||||
const minStartTimeMs = Math.min(...items.map((item) => item.start_time_ms), 0);
|
||||
|
||||
// If resuming the same story, keep position; otherwise start at first item
|
||||
const currentState = get();
|
||||
const shouldResume = currentState.playbackStoryId === storyId && currentState.currentTimeMs > 0;
|
||||
const startTimeMs = shouldResume ? currentState.currentTimeMs : minStartTimeMs;
|
||||
|
||||
console.log('[StoryStore] Play called:', {
|
||||
storyId,
|
||||
itemCount: items.length,
|
||||
items: items.map((i) => ({
|
||||
id: i.generation_id,
|
||||
start: i.start_time_ms,
|
||||
duration: i.duration,
|
||||
})),
|
||||
maxEndTimeMs,
|
||||
minStartTimeMs,
|
||||
startTimeMs,
|
||||
shouldResume,
|
||||
});
|
||||
|
||||
set({
|
||||
isPlaying: true,
|
||||
playbackStoryId: storyId,
|
||||
playbackItems: items,
|
||||
totalDurationMs: maxEndTimeMs,
|
||||
currentTimeMs: startTimeMs,
|
||||
// Reset timing anchors - will be set fresh by the playback hook
|
||||
playbackStartContextTime: null,
|
||||
playbackStartStoryTime: null,
|
||||
});
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
set({
|
||||
isPlaying: false,
|
||||
// Keep timing anchors so we can resume from same position
|
||||
});
|
||||
},
|
||||
|
||||
stop: () => {
|
||||
set({
|
||||
isPlaying: false,
|
||||
currentTimeMs: 0,
|
||||
playbackStoryId: null,
|
||||
playbackItems: null,
|
||||
totalDurationMs: 0,
|
||||
playbackStartContextTime: null,
|
||||
playbackStartStoryTime: null,
|
||||
});
|
||||
},
|
||||
|
||||
seek: (timeMs) => {
|
||||
const state = get();
|
||||
const clampedTime = Math.max(0, Math.min(timeMs, state.totalDurationMs));
|
||||
set({
|
||||
currentTimeMs: clampedTime,
|
||||
// Reset timing anchors - will be set by hook when playback resumes
|
||||
playbackStartContextTime: null,
|
||||
playbackStartStoryTime: null,
|
||||
});
|
||||
},
|
||||
|
||||
setPlaybackTiming: (contextTime, storyTime) => {
|
||||
set({
|
||||
playbackStartContextTime: contextTime,
|
||||
playbackStartStoryTime: storyTime,
|
||||
});
|
||||
},
|
||||
|
||||
setActiveStory: (storyId, items, totalDurationMs) => {
|
||||
const currentState = get();
|
||||
// Only update if switching to a different story
|
||||
if (currentState.playbackStoryId !== storyId) {
|
||||
set({
|
||||
playbackStoryId: storyId,
|
||||
playbackItems: items,
|
||||
totalDurationMs,
|
||||
currentTimeMs: 0,
|
||||
isPlaying: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,18 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Draft state for the create voice profile form
|
||||
export interface ProfileFormDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
language: string;
|
||||
referenceText: string;
|
||||
sampleMode: 'upload' | 'record' | 'system';
|
||||
// Note: File objects can't be persisted, so we store metadata
|
||||
sampleFileName?: string;
|
||||
sampleFileType?: string;
|
||||
sampleFileData?: string; // Base64 encoded
|
||||
}
|
||||
|
||||
interface UIStore {
|
||||
// Sidebar
|
||||
sidebarOpen: boolean;
|
||||
@@ -18,6 +31,10 @@ interface UIStore {
|
||||
selectedProfileId: string | null;
|
||||
setSelectedProfileId: (id: string | null) => void;
|
||||
|
||||
// Profile form draft (for persisting create voice modal state)
|
||||
profileFormDraft: ProfileFormDraft | null;
|
||||
setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
|
||||
|
||||
// Theme
|
||||
theme: 'light' | 'dark';
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
@@ -38,6 +55,9 @@ export const useUIStore = create<UIStore>((set) => ({
|
||||
selectedProfileId: null,
|
||||
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
|
||||
|
||||
profileFormDraft: null,
|
||||
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
|
||||
|
||||
theme: 'light',
|
||||
setTheme: (theme) => {
|
||||
set({ theme });
|
||||
|
||||
+40
-16
@@ -19,8 +19,13 @@ Production-quality FastAPI backend for Qwen3-TTS voice cloning.
|
||||
backend/
|
||||
├── main.py # FastAPI app with all routes
|
||||
├── models.py # Pydantic request/response models
|
||||
├── tts.py # Qwen3-TTS inference
|
||||
├── transcribe.py # Whisper ASR
|
||||
├── platform_detect.py # Platform detection for backend selection
|
||||
├── tts.py # TTS backend abstraction (delegates to MLX or PyTorch)
|
||||
├── transcribe.py # STT backend abstraction (delegates to MLX or PyTorch)
|
||||
├── backends/ # Backend implementations
|
||||
│ ├── __init__.py # Backend factory and protocols
|
||||
│ ├── mlx_backend.py # MLX backend (Apple Silicon)
|
||||
│ └── pytorch_backend.py # PyTorch backend (Windows/Linux/Intel)
|
||||
├── profiles.py # Voice profile CRUD
|
||||
├── history.py # Generation history
|
||||
├── studio.py # Audio editing (TODO)
|
||||
@@ -31,6 +36,15 @@ backend/
|
||||
└── validation.py # Input validation
|
||||
```
|
||||
|
||||
### Backend Selection
|
||||
|
||||
Voicebox automatically selects the best backend based on platform:
|
||||
|
||||
- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration (4-5x faster)
|
||||
- **Windows/Linux/Intel Mac**: Uses PyTorch backend (CUDA GPU if available, CPU fallback)
|
||||
|
||||
The backend is detected at runtime via `platform_detect.py`. Both backends implement the same interface, so the API remains consistent across platforms.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Health & Info
|
||||
@@ -47,12 +61,20 @@ Health check with model status.
|
||||
"status": "healthy",
|
||||
"model_loaded": true,
|
||||
"gpu_available": true,
|
||||
"vram_used_mb": 1024.5
|
||||
"gpu_type": "Metal (Apple Silicon via MLX)",
|
||||
"backend_type": "mlx",
|
||||
"vram_used_mb": null
|
||||
}
|
||||
```
|
||||
|
||||
**Backend Types:**
|
||||
- `"mlx"` - MLX backend (Apple Silicon with Metal acceleration)
|
||||
- `"pytorch"` - PyTorch backend (Windows/Linux/Intel Mac)
|
||||
|
||||
### Voice Profiles
|
||||
|
||||
**Note:** The database is automatically initialized when the server starts. No manual setup required.
|
||||
|
||||
#### `POST /profiles`
|
||||
Create a new voice profile.
|
||||
|
||||
@@ -266,13 +288,12 @@ data/
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Initialize Database
|
||||
|
||||
**Note:** On Apple Silicon, also install MLX dependencies for faster inference:
|
||||
```bash
|
||||
python -c "from database import init_db; init_db()"
|
||||
pip install -r requirements-mlx.txt
|
||||
```
|
||||
|
||||
### 3. Download Models (Automatic)
|
||||
### 2. Download Models (Automatic)
|
||||
|
||||
The Qwen3-TTS models are automatically downloaded from HuggingFace Hub on first use, similar to how Whisper models work.
|
||||
|
||||
@@ -313,18 +334,21 @@ python -m backend.main --host 0.0.0.0 --port 8000
|
||||
|
||||
## Usage Examples
|
||||
|
||||
The desktop app, web client, and current development workflow use `http://localhost:17493` by default.
|
||||
If you launch the backend manually with a different host or port, substitute that address in the examples below.
|
||||
|
||||
### Creating a Voice Profile
|
||||
|
||||
```bash
|
||||
# 1. Create profile
|
||||
curl -X POST http://localhost:8000/profiles \
|
||||
curl -X POST http://localhost:17493/profiles \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "My Voice", "language": "en"}'
|
||||
|
||||
# Response: {"id": "abc-123", ...}
|
||||
|
||||
# 2. Add sample
|
||||
curl -X POST http://localhost:8000/profiles/abc-123/samples \
|
||||
curl -X POST http://localhost:17493/profiles/abc-123/samples \
|
||||
-F "[email protected]" \
|
||||
-F "reference_text=This is my voice sample"
|
||||
```
|
||||
@@ -332,7 +356,7 @@ curl -X POST http://localhost:8000/profiles/abc-123/samples \
|
||||
### Generating Speech
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/generate \
|
||||
curl -X POST http://localhost:17493/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"profile_id": "abc-123",
|
||||
@@ -344,13 +368,13 @@ curl -X POST http://localhost:8000/generate \
|
||||
# Response: {"id": "gen-456", "audio_path": "/path/to/audio.wav", ...}
|
||||
|
||||
# Download audio
|
||||
curl http://localhost:8000/audio/gen-456 -o output.wav
|
||||
curl http://localhost:17493/audio/gen-456 -o output.wav
|
||||
```
|
||||
|
||||
### Transcribing Audio
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/transcribe \
|
||||
curl -X POST http://localhost:17493/transcribe \
|
||||
-F "[email protected]" \
|
||||
-F "language=en"
|
||||
|
||||
@@ -365,12 +389,12 @@ Add multiple samples to a profile for better quality:
|
||||
|
||||
```bash
|
||||
# Add first sample
|
||||
curl -X POST http://localhost:8000/profiles/abc-123/samples \
|
||||
curl -X POST http://localhost:17493/profiles/abc-123/samples \
|
||||
-F "[email protected]" \
|
||||
-F "reference_text=First 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 "[email protected]" \
|
||||
-F "reference_text=Second sample"
|
||||
|
||||
@@ -391,10 +415,10 @@ Models are lazy-loaded and can be manually unloaded:
|
||||
|
||||
```bash
|
||||
# Unload TTS model
|
||||
curl -X POST http://localhost:8000/models/unload
|
||||
curl -X POST http://localhost:17493/models/unload
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
# Backend package
|
||||
|
||||
__version__ = "0.1.13"
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Backend abstraction layer for TTS and STT.
|
||||
|
||||
Provides a unified interface for MLX and PyTorch backends.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from typing import Protocol, Optional, Tuple, List
|
||||
from typing_extensions import runtime_checkable
|
||||
import numpy as np
|
||||
|
||||
from ..platform_detect import get_backend_type
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TTSBackend(Protocol):
|
||||
"""Protocol for TTS backend implementations."""
|
||||
|
||||
async def load_model(self, model_size: str) -> None:
|
||||
"""Load TTS model."""
|
||||
...
|
||||
|
||||
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.
|
||||
|
||||
Returns:
|
||||
Tuple of (voice_prompt_dict, was_cached)
|
||||
"""
|
||||
...
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: List[str],
|
||||
reference_texts: List[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
"""
|
||||
Combine multiple voice prompts.
|
||||
|
||||
Returns:
|
||||
Tuple of (combined_audio_array, 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.
|
||||
|
||||
Returns:
|
||||
Tuple of (audio_array, sample_rate)
|
||||
"""
|
||||
...
|
||||
|
||||
def unload_model(self) -> None:
|
||||
"""Unload model to free memory."""
|
||||
...
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if model is loaded."""
|
||||
...
|
||||
|
||||
def _get_model_path(self, model_size: str) -> str:
|
||||
"""
|
||||
Get model path for a given size.
|
||||
|
||||
Returns:
|
||||
Model path or HuggingFace Hub ID
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class STTBackend(Protocol):
|
||||
"""Protocol for STT (Speech-to-Text) backend implementations."""
|
||||
|
||||
async def load_model(self, model_size: str) -> None:
|
||||
"""Load STT model."""
|
||||
...
|
||||
|
||||
async def transcribe(
|
||||
self,
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio to text.
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
"""
|
||||
...
|
||||
|
||||
def unload_model(self) -> None:
|
||||
"""Unload model to free memory."""
|
||||
...
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if model is loaded."""
|
||||
...
|
||||
|
||||
|
||||
# Global backend instances
|
||||
_tts_backend: Optional[TTSBackend] = None
|
||||
_tts_backends: dict[str, TTSBackend] = {}
|
||||
_tts_backends_lock = threading.Lock()
|
||||
_stt_backend: Optional[STTBackend] = None
|
||||
|
||||
# Supported TTS engines
|
||||
TTS_ENGINES = {
|
||||
"qwen": "Qwen TTS",
|
||||
"luxtts": "LuxTTS",
|
||||
"chatterbox": "Chatterbox TTS",
|
||||
"chatterbox_turbo": "Chatterbox Turbo",
|
||||
}
|
||||
|
||||
|
||||
def get_tts_backend() -> TTSBackend:
|
||||
"""
|
||||
Get or create the default (Qwen) TTS backend instance based on platform.
|
||||
|
||||
Returns:
|
||||
TTS backend instance (MLX or PyTorch)
|
||||
"""
|
||||
return get_tts_backend_for_engine("qwen")
|
||||
|
||||
|
||||
def get_tts_backend_for_engine(engine: str) -> TTSBackend:
|
||||
"""
|
||||
Get or create a TTS backend for the given engine.
|
||||
|
||||
Args:
|
||||
engine: Engine name ("qwen" or "luxtts")
|
||||
|
||||
Returns:
|
||||
TTS backend instance
|
||||
"""
|
||||
global _tts_backends
|
||||
|
||||
# Fast path: check without lock
|
||||
if engine in _tts_backends:
|
||||
return _tts_backends[engine]
|
||||
|
||||
# Slow path: create with lock to avoid duplicate instantiation
|
||||
with _tts_backends_lock:
|
||||
# Double-check after acquiring lock
|
||||
if engine in _tts_backends:
|
||||
return _tts_backends[engine]
|
||||
|
||||
if engine == "qwen":
|
||||
backend_type = get_backend_type()
|
||||
if backend_type == "mlx":
|
||||
from .mlx_backend import MLXTTSBackend
|
||||
backend = MLXTTSBackend()
|
||||
else:
|
||||
from .pytorch_backend import PyTorchTTSBackend
|
||||
backend = PyTorchTTSBackend()
|
||||
elif engine == "luxtts":
|
||||
from .luxtts_backend import LuxTTSBackend
|
||||
backend = LuxTTSBackend()
|
||||
elif engine == "chatterbox":
|
||||
from .chatterbox_backend import ChatterboxTTSBackend
|
||||
backend = ChatterboxTTSBackend()
|
||||
elif engine == "chatterbox_turbo":
|
||||
from .chatterbox_turbo_backend import ChatterboxTurboTTSBackend
|
||||
backend = ChatterboxTurboTTSBackend()
|
||||
else:
|
||||
raise ValueError(f"Unknown TTS engine: {engine}. Supported: {list(TTS_ENGINES.keys())}")
|
||||
|
||||
_tts_backends[engine] = backend
|
||||
return backend
|
||||
|
||||
|
||||
def get_stt_backend() -> STTBackend:
|
||||
"""
|
||||
Get or create STT backend instance based on platform.
|
||||
|
||||
Returns:
|
||||
STT backend instance (MLX or PyTorch)
|
||||
"""
|
||||
global _stt_backend
|
||||
|
||||
if _stt_backend is None:
|
||||
backend_type = get_backend_type()
|
||||
|
||||
if backend_type == "mlx":
|
||||
from .mlx_backend import MLXSTTBackend
|
||||
_stt_backend = MLXSTTBackend()
|
||||
else:
|
||||
from .pytorch_backend import PyTorchSTTBackend
|
||||
_stt_backend = PyTorchSTTBackend()
|
||||
|
||||
return _stt_backend
|
||||
|
||||
|
||||
def reset_backends():
|
||||
"""Reset backend instances (useful for testing)."""
|
||||
global _tts_backend, _tts_backends, _stt_backend
|
||||
_tts_backend = None
|
||||
_tts_backends.clear()
|
||||
_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)
|
||||
@@ -0,0 +1,615 @@
|
||||
"""
|
||||
MLX backend implementation for TTS and STT using mlx-audio.
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Tuple
|
||||
import asyncio
|
||||
import numpy as np
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# PATCH: Import and apply offline patch BEFORE any huggingface_hub usage
|
||||
# This prevents mlx_audio from making network requests when models are cached
|
||||
from ..utils.hf_offline_patch import patch_huggingface_hub_offline, ensure_original_qwen_config_cached
|
||||
patch_huggingface_hub_offline()
|
||||
ensure_original_qwen_config_cached()
|
||||
|
||||
from . import TTSBackend, STTBackend
|
||||
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
||||
from ..utils.audio import normalize_audio, load_audio
|
||||
from ..utils.progress import get_progress_manager
|
||||
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||
from ..utils.tasks import get_task_manager
|
||||
|
||||
LANGUAGE_CODE_TO_NAME = {
|
||||
"zh": "chinese", "en": "english", "ja": "japanese", "ko": "korean",
|
||||
"de": "german", "fr": "french", "ru": "russian", "pt": "portuguese",
|
||||
"es": "spanish", "it": "italian",
|
||||
}
|
||||
|
||||
|
||||
class MLXTTSBackend:
|
||||
"""MLX-based TTS backend using mlx-audio."""
|
||||
|
||||
def __init__(self, model_size: str = "1.7B"):
|
||||
self.model = None
|
||||
self.model_size = model_size
|
||||
self._current_model_size = None
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if model is loaded."""
|
||||
return self.model is not None
|
||||
|
||||
def _get_model_path(self, model_size: str) -> str:
|
||||
"""
|
||||
Get the MLX model path.
|
||||
|
||||
Args:
|
||||
model_size: Model size (1.7B or 0.6B)
|
||||
|
||||
Returns:
|
||||
HuggingFace Hub model ID for MLX
|
||||
"""
|
||||
# MLX model mapping
|
||||
mlx_model_map = {
|
||||
"1.7B": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16",
|
||||
# 0.6B not yet converted to MLX format
|
||||
"0.6B": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16", # Fallback to 1.7B
|
||||
}
|
||||
|
||||
if model_size not in mlx_model_map:
|
||||
raise ValueError(f"Unknown model size: {model_size}")
|
||||
|
||||
hf_model_id = mlx_model_map[model_size]
|
||||
print(f"Will download MLX model from HuggingFace Hub: {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):
|
||||
"""
|
||||
Lazy load the MLX TTS model.
|
||||
|
||||
Args:
|
||||
model_size: Model size to load (1.7B or 0.6B)
|
||||
"""
|
||||
if model_size is None:
|
||||
model_size = self.model_size
|
||||
|
||||
# If already loaded with correct size, return
|
||||
if self.model is not None and self._current_model_size == model_size:
|
||||
return
|
||||
|
||||
# Unload existing model if different size requested
|
||||
if self.model is not None and self._current_model_size != model_size:
|
||||
self.unload_model()
|
||||
|
||||
# Run blocking load in thread pool
|
||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||
|
||||
# Alias for compatibility
|
||||
load_model = load_model_async
|
||||
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
try:
|
||||
# Get model path BEFORE importing mlx_audio
|
||||
model_path = self._get_model_path(model_size)
|
||||
|
||||
# Set up progress tracking
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback
|
||||
# If cached: filter out non-download progress
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
print(f"Loading MLX TTS model {model_size}...")
|
||||
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
# This provides immediate feedback while HuggingFace fetches metadata
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=0, # Will be updated once actual total is known
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# IMPORTANT: Patch tqdm BEFORE importing mlx_audio
|
||||
# Otherwise mlx_audio caches reference to original tqdm
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
|
||||
# PATCH: Force offline mode when model is already cached
|
||||
# This prevents crashes when HuggingFace is unreachable
|
||||
original_hf_hub_offline = os.environ.get("HF_HUB_OFFLINE")
|
||||
if is_cached:
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
print(f"[PATCH] Model {model_size} is cached, forcing HF_HUB_OFFLINE=1 to avoid network requests")
|
||||
|
||||
# Import mlx_audio AFTER patching tqdm
|
||||
from mlx_audio.tts import load
|
||||
|
||||
# Load MLX model (downloads automatically)
|
||||
try:
|
||||
self.model = load(model_path)
|
||||
except Exception as load_error:
|
||||
# If offline mode failed, try with network enabled as fallback
|
||||
if is_cached and "offline" in str(load_error).lower():
|
||||
print(f"[PATCH] Offline load failed, trying with network: {load_error}")
|
||||
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||
self.model = load(model_path)
|
||||
else:
|
||||
raise
|
||||
finally:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
# Restore original HF_HUB_OFFLINE setting
|
||||
if original_hf_hub_offline is not None:
|
||||
os.environ["HF_HUB_OFFLINE"] = original_hf_hub_offline
|
||||
else:
|
||||
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
|
||||
print(f"MLX TTS model {model_size} loaded successfully")
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Error: mlx_audio package not found. Install with: pip install mlx-audio")
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
progress_manager.mark_error(model_name, str(e))
|
||||
task_manager.error_download(model_name, str(e))
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Error loading MLX TTS model: {e}")
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
progress_manager.mark_error(model_name, str(e))
|
||||
task_manager.error_download(model_name, str(e))
|
||||
raise
|
||||
|
||||
def unload_model(self):
|
||||
"""Unload the model to free memory."""
|
||||
if self.model is not None:
|
||||
del self.model
|
||||
self.model = None
|
||||
self._current_model_size = None
|
||||
print("MLX TTS model 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.
|
||||
|
||||
MLX backend stores voice prompt as a dict with audio path and text.
|
||||
The actual voice prompt processing happens during generation.
|
||||
|
||||
Args:
|
||||
audio_path: Path to reference audio file
|
||||
reference_text: Transcript of reference audio
|
||||
use_cache: Whether to use cached prompt if available
|
||||
|
||||
Returns:
|
||||
Tuple of (voice_prompt_dict, was_cached)
|
||||
"""
|
||||
await self.load_model_async(None)
|
||||
|
||||
# Check cache if enabled
|
||||
if use_cache:
|
||||
cache_key = get_cache_key(audio_path, reference_text)
|
||||
cached_prompt = get_cached_voice_prompt(cache_key)
|
||||
if cached_prompt is not None:
|
||||
# Return cached prompt (should be dict format)
|
||||
if isinstance(cached_prompt, dict):
|
||||
# Validate that the cached audio file still exists
|
||||
cached_audio_path = cached_prompt.get("ref_audio") or cached_prompt.get("ref_audio_path")
|
||||
if cached_audio_path and Path(cached_audio_path).exists():
|
||||
return cached_prompt, True
|
||||
else:
|
||||
# Cached file no longer exists, invalidate cache
|
||||
print(f"Cached audio file not found: {cached_audio_path}, regenerating prompt")
|
||||
|
||||
# MLX voice prompt format - store audio path and text
|
||||
# The model will process this during generation
|
||||
voice_prompt_items = {
|
||||
"ref_audio": str(audio_path),
|
||||
"ref_text": reference_text,
|
||||
}
|
||||
|
||||
# Cache if enabled
|
||||
if use_cache:
|
||||
cache_key = get_cache_key(audio_path, reference_text)
|
||||
cache_voice_prompt(cache_key, voice_prompt_items)
|
||||
|
||||
return voice_prompt_items, False
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: List[str],
|
||||
reference_texts: List[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
"""
|
||||
Combine multiple reference samples for better quality.
|
||||
|
||||
Args:
|
||||
audio_paths: List of audio file paths
|
||||
reference_texts: List of reference texts
|
||||
|
||||
Returns:
|
||||
Tuple of (combined_audio, combined_text)
|
||||
"""
|
||||
combined_audio = []
|
||||
|
||||
for audio_path in audio_paths:
|
||||
audio, sr = load_audio(audio_path)
|
||||
audio = normalize_audio(audio)
|
||||
combined_audio.append(audio)
|
||||
|
||||
# Concatenate audio
|
||||
mixed = np.concatenate(combined_audio)
|
||||
mixed = normalize_audio(mixed)
|
||||
|
||||
# Combine texts
|
||||
combined_text = " ".join(reference_texts)
|
||||
|
||||
return mixed, combined_text
|
||||
|
||||
async def 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 voice prompt.
|
||||
|
||||
Args:
|
||||
text: Text to synthesize
|
||||
voice_prompt: Voice prompt dictionary with ref_audio and ref_text
|
||||
language: Language code (en or zh) - may not be fully supported by MLX
|
||||
seed: Random seed for reproducibility
|
||||
instruct: Natural language instruction (may not be supported by MLX)
|
||||
|
||||
Returns:
|
||||
Tuple of (audio_array, sample_rate)
|
||||
"""
|
||||
await self.load_model_async(None)
|
||||
|
||||
print(f"Generating audio for text: {text}")
|
||||
|
||||
def _generate_sync():
|
||||
"""Run synchronous generation in thread pool."""
|
||||
# MLX generate() returns a generator yielding GenerationResult objects
|
||||
audio_chunks = []
|
||||
sample_rate = 24000
|
||||
lang = LANGUAGE_CODE_TO_NAME.get(language, "auto")
|
||||
|
||||
# Set seed if provided (MLX uses numpy random)
|
||||
if seed is not None:
|
||||
import mlx.core as mx
|
||||
np.random.seed(seed)
|
||||
mx.random.seed(seed)
|
||||
|
||||
# Extract voice prompt info
|
||||
ref_audio = voice_prompt.get("ref_audio") or voice_prompt.get("ref_audio_path")
|
||||
ref_text = voice_prompt.get("ref_text", "")
|
||||
|
||||
# Validate that the audio file exists
|
||||
if ref_audio and not Path(ref_audio).exists():
|
||||
print(f"Warning: Audio file not found: {ref_audio}")
|
||||
print("This may be due to a cached voice prompt referencing a deleted temp file.")
|
||||
print("Regenerating without voice prompt.")
|
||||
ref_audio = None
|
||||
|
||||
# Check if model supports voice cloning via generate method
|
||||
# MLX API may support ref_audio parameter directly
|
||||
try:
|
||||
# Try with voice cloning parameters if supported
|
||||
if ref_audio:
|
||||
# Check if generate accepts ref_audio parameter
|
||||
import inspect
|
||||
sig = inspect.signature(self.model.generate)
|
||||
if "ref_audio" in sig.parameters:
|
||||
# Generate with voice cloning
|
||||
for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text, lang_code=lang):
|
||||
audio_chunks.append(np.array(result.audio))
|
||||
sample_rate = result.sample_rate
|
||||
else:
|
||||
# Fallback: generate without voice cloning
|
||||
for result in self.model.generate(text, lang_code=lang):
|
||||
audio_chunks.append(np.array(result.audio))
|
||||
sample_rate = result.sample_rate
|
||||
else:
|
||||
# No voice prompt, generate normally
|
||||
for result in self.model.generate(text, lang_code=lang):
|
||||
audio_chunks.append(np.array(result.audio))
|
||||
sample_rate = result.sample_rate
|
||||
except Exception as e:
|
||||
# If voice cloning fails, try without it
|
||||
print(f"Warning: Voice cloning failed, generating without voice prompt: {e}")
|
||||
for result in self.model.generate(text, lang_code=lang):
|
||||
audio_chunks.append(np.array(result.audio))
|
||||
sample_rate = result.sample_rate
|
||||
|
||||
# Concatenate all chunks
|
||||
if audio_chunks:
|
||||
audio = np.concatenate([np.asarray(chunk, dtype=np.float32) for chunk in audio_chunks])
|
||||
else:
|
||||
# Fallback: empty audio
|
||||
audio = np.array([], dtype=np.float32)
|
||||
|
||||
return audio, sample_rate
|
||||
|
||||
# Run blocking inference in thread pool
|
||||
audio, sample_rate = await asyncio.to_thread(_generate_sync)
|
||||
|
||||
return audio, sample_rate
|
||||
|
||||
|
||||
WHISPER_HF_REPOS = {
|
||||
"base": "openai/whisper-base",
|
||||
"small": "openai/whisper-small",
|
||||
"medium": "openai/whisper-medium",
|
||||
"large": "openai/whisper-large-v3",
|
||||
}
|
||||
|
||||
|
||||
class MLXSTTBackend:
|
||||
"""MLX-based STT backend using mlx-audio Whisper."""
|
||||
|
||||
def __init__(self, model_size: str = "base"):
|
||||
self.model = None
|
||||
self.model_size = model_size
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if model is loaded."""
|
||||
return self.model is not None
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
"""
|
||||
Check if the Whisper model is already cached locally AND fully downloaded.
|
||||
|
||||
Args:
|
||||
model_size: Model size to check
|
||||
|
||||
Returns:
|
||||
True if model is fully cached, False if missing or incomplete
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
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):
|
||||
"""
|
||||
Lazy load the MLX Whisper model.
|
||||
|
||||
Args:
|
||||
model_size: Model size (tiny, base, small, medium, large)
|
||||
"""
|
||||
if model_size is None:
|
||||
model_size = self.model_size
|
||||
|
||||
if self.model is not None and self.model_size == model_size:
|
||||
return
|
||||
|
||||
# Run blocking load in thread pool
|
||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||
|
||||
# Alias for compatibility
|
||||
load_model = load_model_async
|
||||
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
try:
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback and tracker
|
||||
# If cached: filter out non-download progress
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
# Patch tqdm BEFORE importing mlx_audio
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
|
||||
# Import mlx_audio
|
||||
from mlx_audio.stt import load
|
||||
|
||||
# MLX Whisper uses the standard OpenAI models
|
||||
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||
|
||||
print(f"Loading MLX Whisper model {model_size}...")
|
||||
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(progress_model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
progress_manager.update_progress(
|
||||
model_name=progress_model_name,
|
||||
current=0,
|
||||
total=0,
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||
try:
|
||||
self.model = load(model_name)
|
||||
finally:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(progress_model_name)
|
||||
task_manager.complete_download(progress_model_name)
|
||||
|
||||
self.model_size = model_size
|
||||
|
||||
print(f"MLX Whisper model {model_size} loaded successfully")
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Error: mlx_audio package not found. Install with: pip install mlx-audio")
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
progress_manager.mark_error(progress_model_name, str(e))
|
||||
task_manager.error_download(progress_model_name, str(e))
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Error loading MLX Whisper model: {e}")
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
progress_manager.mark_error(progress_model_name, str(e))
|
||||
task_manager.error_download(progress_model_name, str(e))
|
||||
raise
|
||||
|
||||
def unload_model(self):
|
||||
"""Unload the model to free memory."""
|
||||
if self.model is not None:
|
||||
del self.model
|
||||
self.model = None
|
||||
print("MLX Whisper model unloaded")
|
||||
|
||||
async def transcribe(
|
||||
self,
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio to text.
|
||||
|
||||
Args:
|
||||
audio_path: Path to audio file
|
||||
language: Optional language hint (en or zh)
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
"""
|
||||
await self.load_model_async(None)
|
||||
|
||||
def _transcribe_sync():
|
||||
"""Run synchronous transcription in thread pool."""
|
||||
# MLX Whisper transcription using generate method
|
||||
# The generate method accepts audio path directly
|
||||
decode_options = {}
|
||||
if language:
|
||||
decode_options["language"] = language
|
||||
|
||||
result = self.model.generate(str(audio_path), **decode_options)
|
||||
|
||||
# Extract text from result
|
||||
if isinstance(result, str):
|
||||
return result.strip()
|
||||
elif isinstance(result, dict):
|
||||
return result.get("text", "").strip()
|
||||
elif hasattr(result, "text"):
|
||||
return result.text.strip()
|
||||
else:
|
||||
return str(result).strip()
|
||||
|
||||
# Run blocking transcription in thread pool
|
||||
return await asyncio.to_thread(_transcribe_sync)
|
||||
@@ -0,0 +1,627 @@
|
||||
"""
|
||||
PyTorch backend implementation for TTS and STT.
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Tuple
|
||||
import asyncio
|
||||
import torch
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
from . import TTSBackend, STTBackend
|
||||
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
||||
from ..utils.audio import normalize_audio, load_audio
|
||||
from ..utils.progress import get_progress_manager
|
||||
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||
from ..utils.tasks import get_task_manager
|
||||
|
||||
LANGUAGE_CODE_TO_NAME = {
|
||||
"zh": "chinese", "en": "english", "ja": "japanese", "ko": "korean",
|
||||
"de": "german", "fr": "french", "ru": "russian", "pt": "portuguese",
|
||||
"es": "spanish", "it": "italian",
|
||||
}
|
||||
|
||||
|
||||
class PyTorchTTSBackend:
|
||||
"""PyTorch-based TTS backend using Qwen3-TTS."""
|
||||
|
||||
def __init__(self, model_size: str = "1.7B"):
|
||||
self.model = None
|
||||
self.model_size = model_size
|
||||
self.device = self._get_device()
|
||||
self._current_model_size = None
|
||||
|
||||
def _get_device(self) -> str:
|
||||
"""Get the best available device."""
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
# Intel Arc / Intel Xe GPU via intel-extension-for-pytorch (IPEX)
|
||||
try:
|
||||
import intel_extension_for_pytorch # noqa: F401
|
||||
if hasattr(torch, 'xpu') and torch.xpu.is_available():
|
||||
return "xpu"
|
||||
except ImportError:
|
||||
pass
|
||||
# Any GPU on Windows via DirectML (torch-directml)
|
||||
try:
|
||||
import torch_directml
|
||||
if torch_directml.device_count() > 0:
|
||||
return torch_directml.device(0)
|
||||
except ImportError:
|
||||
pass
|
||||
# MPS (Apple Silicon) — kept for completeness but MLX backend is preferred
|
||||
if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||
return "cpu" # MPS disabled for stability; MLX backend handles Apple Silicon
|
||||
return "cpu"
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if model is loaded."""
|
||||
return self.model is not None
|
||||
|
||||
def _get_model_path(self, model_size: str) -> str:
|
||||
"""
|
||||
Get the HuggingFace Hub model ID.
|
||||
|
||||
Args:
|
||||
model_size: Model size (1.7B or 0.6B)
|
||||
|
||||
Returns:
|
||||
HuggingFace Hub model ID
|
||||
"""
|
||||
hf_model_map = {
|
||||
"1.7B": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
||||
"0.6B": "Qwen/Qwen3-TTS-12Hz-0.6B-Base",
|
||||
}
|
||||
|
||||
if model_size not in hf_model_map:
|
||||
raise ValueError(f"Unknown model size: {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):
|
||||
"""
|
||||
Lazy load the TTS model with automatic downloading from HuggingFace Hub.
|
||||
|
||||
Args:
|
||||
model_size: Model size to load (1.7B or 0.6B)
|
||||
"""
|
||||
if model_size is None:
|
||||
model_size = self.model_size
|
||||
|
||||
# If already loaded with correct size, return
|
||||
if self.model is not None and self._current_model_size == model_size:
|
||||
return
|
||||
|
||||
# Unload existing model if different size requested
|
||||
if self.model is not None and self._current_model_size != model_size:
|
||||
self.unload_model()
|
||||
|
||||
# Run blocking load in thread pool
|
||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||
|
||||
# Alias for compatibility
|
||||
load_model = load_model_async
|
||||
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
try:
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback and tracker
|
||||
# If cached: filter out non-download progress (like "Segment 1/1" during generation)
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
# Patch tqdm BEFORE importing qwen_tts
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
|
||||
# Import qwen_tts
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
# Get model path (local or HuggingFace Hub ID)
|
||||
model_path = self._get_model_path(model_size)
|
||||
|
||||
print(f"Loading TTS model {model_size} on {self.device}...")
|
||||
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
progress_manager.update_progress(
|
||||
model_name=model_name,
|
||||
current=0,
|
||||
total=0, # Will be updated once actual total is known
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||
try:
|
||||
# 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:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(model_name)
|
||||
task_manager.complete_download(model_name)
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
|
||||
print(f"TTS model {model_size} loaded successfully")
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Error: qwen_tts package not found. Install with: pip install git+https://github.com/QwenLM/Qwen3-TTS.git")
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
progress_manager.mark_error(model_name, str(e))
|
||||
task_manager.error_download(model_name, str(e))
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Error loading TTS model: {e}")
|
||||
print(f"Tip: The model will be automatically downloaded from HuggingFace Hub on first use.")
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
progress_manager.mark_error(model_name, str(e))
|
||||
task_manager.error_download(model_name, str(e))
|
||||
raise
|
||||
|
||||
def unload_model(self):
|
||||
"""Unload the model to free memory."""
|
||||
if self.model is not None:
|
||||
del self.model
|
||||
self.model = None
|
||||
self._current_model_size = None
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
print("TTS model 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.
|
||||
|
||||
Args:
|
||||
audio_path: Path to reference audio file
|
||||
reference_text: Transcript of reference audio
|
||||
use_cache: Whether to use cached prompt if available
|
||||
|
||||
Returns:
|
||||
Tuple of (voice_prompt_dict, was_cached)
|
||||
"""
|
||||
await self.load_model_async(None)
|
||||
|
||||
# Check cache if enabled
|
||||
if use_cache:
|
||||
cache_key = get_cache_key(audio_path, reference_text)
|
||||
cached_prompt = get_cached_voice_prompt(cache_key)
|
||||
if cached_prompt is not None:
|
||||
# Cache stores as torch.Tensor but actual prompt is dict
|
||||
# Convert if needed
|
||||
if isinstance(cached_prompt, dict):
|
||||
# For PyTorch backend, the dict should contain tensors, not file paths
|
||||
# So we can safely return it
|
||||
return cached_prompt, True
|
||||
elif isinstance(cached_prompt, torch.Tensor):
|
||||
# Legacy cache format - convert to dict
|
||||
# This shouldn't happen in practice, but handle it
|
||||
return {"prompt": cached_prompt}, True
|
||||
|
||||
def _create_prompt_sync():
|
||||
"""Run synchronous voice prompt creation in thread pool."""
|
||||
return self.model.create_voice_clone_prompt(
|
||||
ref_audio=str(audio_path),
|
||||
ref_text=reference_text,
|
||||
x_vector_only_mode=False,
|
||||
)
|
||||
|
||||
# Run blocking operation in thread pool
|
||||
voice_prompt_items = await asyncio.to_thread(_create_prompt_sync)
|
||||
|
||||
# Cache if enabled
|
||||
if use_cache:
|
||||
cache_key = get_cache_key(audio_path, reference_text)
|
||||
cache_voice_prompt(cache_key, voice_prompt_items)
|
||||
|
||||
return voice_prompt_items, False
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: List[str],
|
||||
reference_texts: List[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
"""
|
||||
Combine multiple reference samples for better quality.
|
||||
|
||||
Args:
|
||||
audio_paths: List of audio file paths
|
||||
reference_texts: List of reference texts
|
||||
|
||||
Returns:
|
||||
Tuple of (combined_audio, combined_text)
|
||||
"""
|
||||
combined_audio = []
|
||||
|
||||
for audio_path in audio_paths:
|
||||
audio, sr = load_audio(audio_path)
|
||||
audio = normalize_audio(audio)
|
||||
combined_audio.append(audio)
|
||||
|
||||
# Concatenate audio
|
||||
mixed = np.concatenate(combined_audio)
|
||||
mixed = normalize_audio(mixed)
|
||||
|
||||
# Combine texts
|
||||
combined_text = " ".join(reference_texts)
|
||||
|
||||
return mixed, combined_text
|
||||
|
||||
async def 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 voice prompt.
|
||||
|
||||
Args:
|
||||
text: Text to synthesize
|
||||
voice_prompt: Voice prompt dictionary from create_voice_prompt
|
||||
language: Language code (en or zh)
|
||||
seed: Random seed for reproducibility
|
||||
instruct: Natural language instruction for speech delivery control
|
||||
|
||||
Returns:
|
||||
Tuple of (audio_array, sample_rate)
|
||||
"""
|
||||
# Load model
|
||||
await self.load_model_async(None)
|
||||
|
||||
def _generate_sync():
|
||||
"""Run synchronous generation in thread pool."""
|
||||
# Set seed if provided
|
||||
if seed is not None:
|
||||
torch.manual_seed(seed)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed(seed)
|
||||
|
||||
# Generate audio - this is the blocking operation
|
||||
wavs, sample_rate = self.model.generate_voice_clone(
|
||||
text=text,
|
||||
voice_clone_prompt=voice_prompt,
|
||||
language=LANGUAGE_CODE_TO_NAME.get(language, "auto"),
|
||||
instruct=instruct,
|
||||
)
|
||||
return wavs[0], sample_rate
|
||||
|
||||
# Run blocking inference in thread pool to avoid blocking event loop
|
||||
audio, sample_rate = await asyncio.to_thread(_generate_sync)
|
||||
|
||||
return audio, sample_rate
|
||||
|
||||
|
||||
WHISPER_HF_REPOS = {
|
||||
"base": "openai/whisper-base",
|
||||
"small": "openai/whisper-small",
|
||||
"medium": "openai/whisper-medium",
|
||||
"large": "openai/whisper-large-v3",
|
||||
"turbo": "openai/whisper-large-v3-turbo",
|
||||
}
|
||||
|
||||
|
||||
class PyTorchSTTBackend:
|
||||
"""PyTorch-based STT backend using Whisper."""
|
||||
|
||||
def __init__(self, model_size: str = "base"):
|
||||
self.model = None
|
||||
self.processor = None
|
||||
self.model_size = model_size
|
||||
self.device = self._get_device()
|
||||
|
||||
def _get_device(self) -> str:
|
||||
"""Get the best available device."""
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
# Intel Arc / Intel Xe GPU via intel-extension-for-pytorch (IPEX)
|
||||
try:
|
||||
import intel_extension_for_pytorch # noqa: F401
|
||||
if hasattr(torch, 'xpu') and torch.xpu.is_available():
|
||||
return "xpu"
|
||||
except ImportError:
|
||||
pass
|
||||
# Any GPU on Windows via DirectML (torch-directml)
|
||||
try:
|
||||
import torch_directml
|
||||
if torch_directml.device_count() > 0:
|
||||
return torch_directml.device(0)
|
||||
except ImportError:
|
||||
pass
|
||||
if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||
return "cpu" # MPS disabled for stability
|
||||
return "cpu"
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if model is loaded."""
|
||||
return self.model is not None
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
"""
|
||||
Check if the Whisper model is already cached locally AND fully downloaded.
|
||||
|
||||
Args:
|
||||
model_size: Model size to check
|
||||
|
||||
Returns:
|
||||
True if model is fully cached, False if missing or incomplete
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
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):
|
||||
"""
|
||||
Lazy load the Whisper model.
|
||||
|
||||
Args:
|
||||
model_size: Model size (tiny, base, small, medium, large)
|
||||
"""
|
||||
print(f"[DEBUG] load_model_async called with size: {model_size}")
|
||||
if model_size is None:
|
||||
model_size = self.model_size
|
||||
|
||||
print(f"[DEBUG] Model already loaded? {self.model is not None}, current size: {self.model_size}, requested: {model_size}")
|
||||
if self.model is not None and self.model_size == model_size:
|
||||
print(f"[DEBUG] Early return - model already loaded")
|
||||
return
|
||||
|
||||
print(f"[DEBUG] Calling asyncio.to_thread for _load_model_sync")
|
||||
# Run blocking load in thread pool
|
||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||
print(f"[DEBUG] asyncio.to_thread completed")
|
||||
|
||||
# Alias for compatibility
|
||||
load_model = load_model_async
|
||||
|
||||
def _load_model_sync(self, model_size: str):
|
||||
"""Synchronous model loading."""
|
||||
print(f"[DEBUG] _load_model_sync called for Whisper {model_size}")
|
||||
try:
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
|
||||
# Check if model is already cached
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Set up progress callback and tracker
|
||||
# If cached: filter out non-download progress
|
||||
# If not cached: report all progress (we're actually downloading)
|
||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
||||
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||
|
||||
# Patch tqdm BEFORE importing transformers
|
||||
print("[DEBUG] Starting tqdm patch BEFORE transformers import")
|
||||
tracker_context = tracker.patch_download()
|
||||
tracker_context.__enter__()
|
||||
print("[DEBUG] tqdm patched, now importing transformers")
|
||||
|
||||
# Import transformers
|
||||
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||
|
||||
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||
print(f"[DEBUG] Model name: {model_name}")
|
||||
|
||||
print(f"Loading Whisper model {model_size} on {self.device}...")
|
||||
|
||||
# Only track download progress if model is NOT cached
|
||||
if not is_cached:
|
||||
# Start tracking download task
|
||||
task_manager.start_download(progress_model_name)
|
||||
|
||||
# Initialize progress state so SSE endpoint has initial data to send
|
||||
progress_manager.update_progress(
|
||||
model_name=progress_model_name,
|
||||
current=0,
|
||||
total=0, # Will be updated once actual total is known
|
||||
filename="Connecting to HuggingFace...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Load models (tqdm is patched, but filters out non-download progress)
|
||||
try:
|
||||
self.processor = WhisperProcessor.from_pretrained(model_name)
|
||||
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
||||
finally:
|
||||
# Exit the patch context
|
||||
tracker_context.__exit__(None, None, None)
|
||||
|
||||
# Only mark download as complete if we were tracking it
|
||||
if not is_cached:
|
||||
progress_manager.mark_complete(progress_model_name)
|
||||
task_manager.complete_download(progress_model_name)
|
||||
|
||||
self.model.to(self.device)
|
||||
self.model_size = model_size
|
||||
|
||||
print(f"Whisper model {model_size} loaded successfully")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading Whisper model: {e}")
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
progress_manager.mark_error(progress_model_name, str(e))
|
||||
task_manager.error_download(progress_model_name, str(e))
|
||||
raise
|
||||
|
||||
def unload_model(self):
|
||||
"""Unload the model to free memory."""
|
||||
if self.model is not None:
|
||||
del self.model
|
||||
del self.processor
|
||||
self.model = None
|
||||
self.processor = None
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
print("Whisper model unloaded")
|
||||
|
||||
async def transcribe(
|
||||
self,
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio to text.
|
||||
|
||||
Args:
|
||||
audio_path: Path to audio file
|
||||
language: Optional language hint (en or zh)
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
"""
|
||||
await self.load_model_async(None)
|
||||
|
||||
def _transcribe_sync():
|
||||
"""Run synchronous transcription in thread pool."""
|
||||
# Load audio
|
||||
audio, sr = load_audio(audio_path, sample_rate=16000)
|
||||
|
||||
# Process audio
|
||||
inputs = self.processor(
|
||||
audio,
|
||||
sampling_rate=16000,
|
||||
return_tensors="pt",
|
||||
)
|
||||
inputs = inputs.to(self.device)
|
||||
|
||||
# Generate transcription
|
||||
# If language is provided, force it; otherwise let Whisper auto-detect
|
||||
generate_kwargs = {}
|
||||
if language:
|
||||
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
||||
language=language,
|
||||
task="transcribe",
|
||||
)
|
||||
generate_kwargs["forced_decoder_ids"] = forced_decoder_ids
|
||||
|
||||
with torch.no_grad():
|
||||
predicted_ids = self.model.generate(
|
||||
inputs["input_features"],
|
||||
**generate_kwargs,
|
||||
)
|
||||
|
||||
# Decode
|
||||
transcription = self.processor.batch_decode(
|
||||
predicted_ids,
|
||||
skip_special_tokens=True,
|
||||
)[0]
|
||||
|
||||
return transcription.strip()
|
||||
|
||||
# Run blocking transcription in thread pool
|
||||
return await asyncio.to_thread(_transcribe_sync)
|
||||
+74
-12
@@ -1,32 +1,48 @@
|
||||
"""
|
||||
PyInstaller build script for creating standalone Python server binary.
|
||||
|
||||
Usage:
|
||||
python build_binary.py # Build default (CPU) server binary
|
||||
python build_binary.py --cuda # Build CUDA-enabled server binary
|
||||
"""
|
||||
|
||||
import PyInstaller.__main__
|
||||
import argparse
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def build_server():
|
||||
"""Build Python server as standalone binary."""
|
||||
def is_apple_silicon():
|
||||
"""Check if running on Apple Silicon."""
|
||||
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
||||
|
||||
|
||||
def build_server(cuda=False):
|
||||
"""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
|
||||
|
||||
# Check for local editable qwen_tts install
|
||||
local_qwen_path = Path.home() / 'Projects' / 'voice' / 'Qwen3-TTS'
|
||||
binary_name = 'voicebox-server-cuda' if cuda else 'voicebox-server'
|
||||
|
||||
# PyInstaller arguments
|
||||
args = [
|
||||
'server.py', # Use server.py as entry point instead of main.py
|
||||
'--onefile',
|
||||
'--name', 'voicebox-server',
|
||||
'--name', binary_name,
|
||||
]
|
||||
|
||||
# Add local qwen_tts path if it exists (for editable installs)
|
||||
if local_qwen_path.exists():
|
||||
args.extend(['--paths', str(local_qwen_path)])
|
||||
print(f"Using local qwen_tts source from: {local_qwen_path}")
|
||||
# Add local qwen_tts path if specified (for editable installs)
|
||||
qwen_tts_path = os.getenv('QWEN_TTS_PATH')
|
||||
if qwen_tts_path and Path(qwen_tts_path).exists():
|
||||
args.extend(['--paths', str(qwen_tts_path)])
|
||||
print(f"Using local qwen_tts source from: {qwen_tts_path}")
|
||||
|
||||
# Add hidden imports
|
||||
# Add common hidden imports
|
||||
args.extend([
|
||||
'--hidden-import', 'backend',
|
||||
'--hidden-import', 'backend.main',
|
||||
@@ -37,11 +53,15 @@ def build_server():
|
||||
'--hidden-import', 'backend.history',
|
||||
'--hidden-import', 'backend.tts',
|
||||
'--hidden-import', 'backend.transcribe',
|
||||
'--hidden-import', 'backend.platform_detect',
|
||||
'--hidden-import', 'backend.backends',
|
||||
'--hidden-import', 'backend.backends.pytorch_backend',
|
||||
'--hidden-import', 'backend.utils.audio',
|
||||
'--hidden-import', 'backend.utils.cache',
|
||||
'--hidden-import', 'backend.utils.progress',
|
||||
'--hidden-import', 'backend.utils.hf_progress',
|
||||
'--hidden-import', 'backend.utils.validation',
|
||||
'--hidden-import', 'backend.cuda_download',
|
||||
'--hidden-import', 'torch',
|
||||
'--hidden-import', 'transformers',
|
||||
'--hidden-import', 'fastapi',
|
||||
@@ -61,6 +81,41 @@ def build_server():
|
||||
# Fix for pkg_resources and jaraco namespace packages
|
||||
'--hidden-import', 'pkg_resources.extern',
|
||||
'--collect-submodules', 'jaraco',
|
||||
])
|
||||
|
||||
# Add CUDA-specific hidden imports
|
||||
if cuda:
|
||||
print("Building with CUDA support")
|
||||
args.extend([
|
||||
'--hidden-import', 'torch.cuda',
|
||||
'--hidden-import', 'torch.backends.cudnn',
|
||||
])
|
||||
|
||||
# Add MLX-specific imports if building on Apple Silicon (never for CUDA builds)
|
||||
if is_apple_silicon() and not cuda:
|
||||
print("Building for Apple Silicon - including MLX dependencies")
|
||||
args.extend([
|
||||
'--hidden-import', 'backend.backends.mlx_backend',
|
||||
'--hidden-import', 'mlx',
|
||||
'--hidden-import', 'mlx.core',
|
||||
'--hidden-import', 'mlx.nn',
|
||||
'--hidden-import', 'mlx_audio',
|
||||
'--hidden-import', 'mlx_audio.tts',
|
||||
'--hidden-import', 'mlx_audio.stt',
|
||||
'--collect-submodules', 'mlx',
|
||||
'--collect-submodules', 'mlx_audio',
|
||||
# Use --collect-all so PyInstaller bundles both data files AND
|
||||
# native shared libraries (.dylib, .metallib) for MLX.
|
||||
# Previously only --collect-data was used, which caused MLX to
|
||||
# raise OSError at runtime inside the bundled binary because
|
||||
# the Metal shader libraries were missing.
|
||||
'--collect-all', 'mlx',
|
||||
'--collect-all', 'mlx_audio',
|
||||
])
|
||||
elif not cuda:
|
||||
print("Building for non-Apple Silicon platform - PyTorch only")
|
||||
|
||||
args.extend([
|
||||
'--noconfirm',
|
||||
'--clean',
|
||||
])
|
||||
@@ -71,8 +126,15 @@ def build_server():
|
||||
# Run PyInstaller
|
||||
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__':
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Audio channel management module.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import (
|
||||
AudioChannelCreate,
|
||||
AudioChannelUpdate,
|
||||
AudioChannelResponse,
|
||||
ChannelVoiceAssignment,
|
||||
ProfileChannelAssignment,
|
||||
)
|
||||
from .database import (
|
||||
AudioChannel as DBAudioChannel,
|
||||
ChannelDeviceMapping as DBChannelDeviceMapping,
|
||||
ProfileChannelMapping as DBProfileChannelMapping,
|
||||
VoiceProfile as DBVoiceProfile,
|
||||
)
|
||||
|
||||
|
||||
async def list_channels(db: Session) -> List[AudioChannelResponse]:
|
||||
"""List all audio channels."""
|
||||
channels = db.query(DBAudioChannel).all()
|
||||
result = []
|
||||
|
||||
for channel in channels:
|
||||
# Get device IDs for this channel
|
||||
device_mappings = db.query(DBChannelDeviceMapping).filter_by(
|
||||
channel_id=channel.id
|
||||
).all()
|
||||
device_ids = [m.device_id for m in device_mappings]
|
||||
|
||||
result.append(AudioChannelResponse(
|
||||
id=channel.id,
|
||||
name=channel.name,
|
||||
is_default=channel.is_default,
|
||||
device_ids=device_ids,
|
||||
created_at=channel.created_at,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def get_channel(channel_id: str, db: Session) -> Optional[AudioChannelResponse]:
|
||||
"""Get a channel by ID."""
|
||||
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
return None
|
||||
|
||||
# Get device IDs
|
||||
device_mappings = db.query(DBChannelDeviceMapping).filter_by(
|
||||
channel_id=channel.id
|
||||
).all()
|
||||
device_ids = [m.device_id for m in device_mappings]
|
||||
|
||||
return AudioChannelResponse(
|
||||
id=channel.id,
|
||||
name=channel.name,
|
||||
is_default=channel.is_default,
|
||||
device_ids=device_ids,
|
||||
created_at=channel.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def create_channel(
|
||||
data: AudioChannelCreate,
|
||||
db: Session,
|
||||
) -> AudioChannelResponse:
|
||||
"""Create a new audio channel."""
|
||||
# Check if name already exists
|
||||
existing = db.query(DBAudioChannel).filter_by(name=data.name).first()
|
||||
if existing:
|
||||
raise ValueError(f"Channel with name '{data.name}' already exists")
|
||||
|
||||
# Create channel
|
||||
channel = DBAudioChannel(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data.name,
|
||||
is_default=False,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(channel)
|
||||
db.flush()
|
||||
|
||||
# Add device mappings
|
||||
for device_id in data.device_ids:
|
||||
mapping = DBChannelDeviceMapping(
|
||||
id=str(uuid.uuid4()),
|
||||
channel_id=channel.id,
|
||||
device_id=device_id,
|
||||
)
|
||||
db.add(mapping)
|
||||
|
||||
db.commit()
|
||||
db.refresh(channel)
|
||||
|
||||
return AudioChannelResponse(
|
||||
id=channel.id,
|
||||
name=channel.name,
|
||||
is_default=channel.is_default,
|
||||
device_ids=data.device_ids,
|
||||
created_at=channel.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def update_channel(
|
||||
channel_id: str,
|
||||
data: AudioChannelUpdate,
|
||||
db: Session,
|
||||
) -> Optional[AudioChannelResponse]:
|
||||
"""Update an audio channel."""
|
||||
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
return None
|
||||
|
||||
if channel.is_default:
|
||||
raise ValueError("Cannot modify the default channel")
|
||||
|
||||
# Update name if provided
|
||||
if data.name is not None:
|
||||
# Check if name already exists (excluding current channel)
|
||||
existing = db.query(DBAudioChannel).filter(
|
||||
DBAudioChannel.name == data.name,
|
||||
DBAudioChannel.id != channel_id
|
||||
).first()
|
||||
if existing:
|
||||
raise ValueError(f"Channel with name '{data.name}' already exists")
|
||||
channel.name = data.name
|
||||
|
||||
# Update device mappings if provided
|
||||
if data.device_ids is not None:
|
||||
# Delete existing mappings
|
||||
db.query(DBChannelDeviceMapping).filter_by(channel_id=channel_id).delete()
|
||||
|
||||
# Add new mappings
|
||||
for device_id in data.device_ids:
|
||||
mapping = DBChannelDeviceMapping(
|
||||
id=str(uuid.uuid4()),
|
||||
channel_id=channel.id,
|
||||
device_id=device_id,
|
||||
)
|
||||
db.add(mapping)
|
||||
|
||||
db.commit()
|
||||
db.refresh(channel)
|
||||
|
||||
# Get updated device IDs
|
||||
device_mappings = db.query(DBChannelDeviceMapping).filter_by(
|
||||
channel_id=channel.id
|
||||
).all()
|
||||
device_ids = [m.device_id for m in device_mappings]
|
||||
|
||||
return AudioChannelResponse(
|
||||
id=channel.id,
|
||||
name=channel.name,
|
||||
is_default=channel.is_default,
|
||||
device_ids=device_ids,
|
||||
created_at=channel.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def delete_channel(channel_id: str, db: Session) -> bool:
|
||||
"""Delete an audio channel."""
|
||||
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
return False
|
||||
|
||||
if channel.is_default:
|
||||
raise ValueError("Cannot delete the default channel")
|
||||
|
||||
# Delete device mappings
|
||||
db.query(DBChannelDeviceMapping).filter_by(channel_id=channel_id).delete()
|
||||
|
||||
# Delete profile-channel mappings
|
||||
db.query(DBProfileChannelMapping).filter_by(channel_id=channel_id).delete()
|
||||
|
||||
# Delete channel
|
||||
db.delete(channel)
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def get_channel_voices(channel_id: str, db: Session) -> List[str]:
|
||||
"""Get list of profile IDs assigned to a channel."""
|
||||
mappings = db.query(DBProfileChannelMapping).filter_by(
|
||||
channel_id=channel_id
|
||||
).all()
|
||||
return [m.profile_id for m in mappings]
|
||||
|
||||
|
||||
async def set_channel_voices(
|
||||
channel_id: str,
|
||||
data: ChannelVoiceAssignment,
|
||||
db: Session,
|
||||
) -> None:
|
||||
"""Set which voices are assigned to a channel."""
|
||||
# Verify channel exists
|
||||
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
raise ValueError(f"Channel {channel_id} not found")
|
||||
|
||||
# Verify all profiles exist
|
||||
for profile_id in data.profile_ids:
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||
if not profile:
|
||||
raise ValueError(f"Profile {profile_id} not found")
|
||||
|
||||
# Delete existing mappings for this channel
|
||||
db.query(DBProfileChannelMapping).filter_by(channel_id=channel_id).delete()
|
||||
|
||||
# Add new mappings
|
||||
for profile_id in data.profile_ids:
|
||||
mapping = DBProfileChannelMapping(
|
||||
profile_id=profile_id,
|
||||
channel_id=channel_id,
|
||||
)
|
||||
db.add(mapping)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
async def get_profile_channels(profile_id: str, db: Session) -> List[str]:
|
||||
"""Get list of channel IDs assigned to a profile."""
|
||||
mappings = db.query(DBProfileChannelMapping).filter_by(
|
||||
profile_id=profile_id
|
||||
).all()
|
||||
return [m.channel_id for m in mappings]
|
||||
|
||||
|
||||
async def set_profile_channels(
|
||||
profile_id: str,
|
||||
data: ProfileChannelAssignment,
|
||||
db: Session,
|
||||
) -> None:
|
||||
"""Set which channels a profile is assigned to."""
|
||||
# Verify profile exists
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||
if not profile:
|
||||
raise ValueError(f"Profile {profile_id} not found")
|
||||
|
||||
# Verify all channels exist
|
||||
for channel_id in data.channel_ids:
|
||||
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
raise ValueError(f"Channel {channel_id} not found")
|
||||
|
||||
# Delete existing mappings for this profile
|
||||
db.query(DBProfileChannelMapping).filter_by(profile_id=profile_id).delete()
|
||||
|
||||
# Add new mappings
|
||||
for channel_id in data.channel_ids:
|
||||
mapping = DBProfileChannelMapping(
|
||||
profile_id=profile_id,
|
||||
channel_id=channel_id,
|
||||
)
|
||||
db.add(mapping)
|
||||
|
||||
db.commit()
|
||||
@@ -4,8 +4,17 @@ Configuration module for voicebox backend.
|
||||
Handles data directory configuration for production bundling.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Allow users to override the HuggingFace model download directory.
|
||||
# Set VOICEBOX_MODELS_DIR to an absolute path before starting the server.
|
||||
# This sets HF_HUB_CACHE so all huggingface_hub downloads go to that path.
|
||||
_custom_models_dir = os.environ.get("VOICEBOX_MODELS_DIR")
|
||||
if _custom_models_dir:
|
||||
os.environ["HF_HUB_CACHE"] = _custom_models_dir
|
||||
print(f"[config] Model download path set to: {_custom_models_dir}")
|
||||
|
||||
# Default data directory (used in development)
|
||||
_data_dir = Path("data")
|
||||
|
||||
|
||||
@@ -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
|
||||
+207
-2
@@ -2,7 +2,7 @@
|
||||
SQLite database ORM using SQLAlchemy.
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, Column, String, Integer, Float, DateTime, Text, ForeignKey
|
||||
from sqlalchemy import create_engine, Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from datetime import datetime
|
||||
@@ -17,11 +17,12 @@ Base = declarative_base()
|
||||
class VoiceProfile(Base):
|
||||
"""Voice profile database model."""
|
||||
__tablename__ = "profiles"
|
||||
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, unique=True, nullable=False)
|
||||
description = Column(Text)
|
||||
language = Column(String, default="en")
|
||||
avatar_path = Column(String, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@@ -51,6 +52,31 @@ class Generation(Base):
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Story(Base):
|
||||
"""Story database model."""
|
||||
__tablename__ = "stories"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class StoryItem(Base):
|
||||
"""Story item database model (links generations to stories)."""
|
||||
__tablename__ = "story_items"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
story_id = Column(String, ForeignKey("stories.id"), nullable=False)
|
||||
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
|
||||
start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start
|
||||
track = Column(Integer, nullable=False, default=0) # Track number (0 = main track)
|
||||
trim_start_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from start
|
||||
trim_end_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from end
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""Audio studio project database model."""
|
||||
__tablename__ = "projects"
|
||||
@@ -62,6 +88,33 @@ class Project(Base):
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class AudioChannel(Base):
|
||||
"""Audio channel (bus) database model."""
|
||||
__tablename__ = "audio_channels"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False)
|
||||
is_default = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class ChannelDeviceMapping(Base):
|
||||
"""Mapping between channels and OS audio devices."""
|
||||
__tablename__ = "channel_device_mappings"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
channel_id = Column(String, ForeignKey("audio_channels.id"), nullable=False)
|
||||
device_id = Column(String, nullable=False) # OS device identifier
|
||||
|
||||
|
||||
class ProfileChannelMapping(Base):
|
||||
"""Mapping between voice profiles and audio channels (many-to-many)."""
|
||||
__tablename__ = "profile_channel_mappings"
|
||||
|
||||
profile_id = Column(String, ForeignKey("profiles.id"), primary_key=True)
|
||||
channel_id = Column(String, ForeignKey("audio_channels.id"), primary_key=True)
|
||||
|
||||
|
||||
# Database setup will be initialized in init_db()
|
||||
engine = None
|
||||
SessionLocal = None
|
||||
@@ -81,7 +134,159 @@ def init_db():
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Run migrations before creating tables
|
||||
_run_migrations(engine)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Create default channel if it doesn't exist
|
||||
db = SessionLocal()
|
||||
try:
|
||||
default_channel = db.query(AudioChannel).filter(AudioChannel.is_default == True).first()
|
||||
if not default_channel:
|
||||
default_channel = AudioChannel(
|
||||
id=str(uuid.uuid4()),
|
||||
name="Default",
|
||||
is_default=True
|
||||
)
|
||||
db.add(default_channel)
|
||||
|
||||
# Assign all existing profiles to default channel
|
||||
profiles = db.query(VoiceProfile).all()
|
||||
for profile in profiles:
|
||||
mapping = ProfileChannelMapping(
|
||||
profile_id=profile.id,
|
||||
channel_id=default_channel.id
|
||||
)
|
||||
db.add(mapping)
|
||||
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _run_migrations(engine):
|
||||
"""Run database migrations."""
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
inspector = inspect(engine)
|
||||
|
||||
# Check if story_items table exists
|
||||
if 'story_items' not in inspector.get_table_names():
|
||||
return # Table doesn't exist yet, will be created fresh
|
||||
|
||||
# Get columns in story_items table
|
||||
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||
|
||||
# Migration: Remove position column and ensure start_time_ms exists
|
||||
# SQLite doesn't support DROP COLUMN easily, so we recreate the table
|
||||
if 'position' in columns:
|
||||
print("Migrating story_items: removing position column, using start_time_ms")
|
||||
|
||||
with engine.connect() as conn:
|
||||
# Check if start_time_ms already exists
|
||||
has_start_time = 'start_time_ms' in columns
|
||||
|
||||
if not has_start_time:
|
||||
# First, add the new column temporarily
|
||||
conn.execute(text("ALTER TABLE story_items ADD COLUMN start_time_ms INTEGER DEFAULT 0"))
|
||||
|
||||
# Calculate timecodes from position ordering
|
||||
result = conn.execute(text("""
|
||||
SELECT si.id, si.story_id, si.position, g.duration
|
||||
FROM story_items si
|
||||
JOIN generations g ON si.generation_id = g.id
|
||||
ORDER BY si.story_id, si.position
|
||||
"""))
|
||||
|
||||
rows = result.fetchall()
|
||||
|
||||
current_story_id = None
|
||||
current_time_ms = 0
|
||||
|
||||
for row in rows:
|
||||
item_id, story_id, position, duration = row
|
||||
|
||||
if story_id != current_story_id:
|
||||
current_story_id = story_id
|
||||
current_time_ms = 0
|
||||
|
||||
conn.execute(
|
||||
text("UPDATE story_items SET start_time_ms = :time WHERE id = :id"),
|
||||
{"time": current_time_ms, "id": item_id}
|
||||
)
|
||||
|
||||
current_time_ms += int(duration * 1000) + 200
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Now recreate the table without the position column
|
||||
# 1. Create new table
|
||||
conn.execute(text("""
|
||||
CREATE TABLE story_items_new (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
story_id VARCHAR NOT NULL,
|
||||
generation_id VARCHAR NOT NULL,
|
||||
start_time_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
FOREIGN KEY (story_id) REFERENCES stories(id),
|
||||
FOREIGN KEY (generation_id) REFERENCES generations(id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# 2. Copy data
|
||||
conn.execute(text("""
|
||||
INSERT INTO story_items_new (id, story_id, generation_id, start_time_ms, created_at)
|
||||
SELECT id, story_id, generation_id, start_time_ms, created_at FROM story_items
|
||||
"""))
|
||||
|
||||
# 3. Drop old table
|
||||
conn.execute(text("DROP TABLE story_items"))
|
||||
|
||||
# 4. Rename new table
|
||||
conn.execute(text("ALTER TABLE story_items_new RENAME TO story_items"))
|
||||
|
||||
conn.commit()
|
||||
print("Migrated story_items table to use start_time_ms (removed position column)")
|
||||
|
||||
# Migration: Add track column if it doesn't exist
|
||||
# Re-check columns after potential position migration
|
||||
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||
if 'track' not in columns:
|
||||
print("Migrating story_items: adding track column")
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE story_items ADD COLUMN track INTEGER NOT NULL DEFAULT 0"))
|
||||
conn.commit()
|
||||
print("Added track column to story_items")
|
||||
|
||||
# Migration: Add trim columns if they don't exist
|
||||
# Re-check columns after potential track migration
|
||||
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||
if 'trim_start_ms' not in columns:
|
||||
print("Migrating story_items: adding trim_start_ms column")
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE story_items ADD COLUMN trim_start_ms INTEGER NOT NULL DEFAULT 0"))
|
||||
conn.commit()
|
||||
print("Added trim_start_ms column to story_items")
|
||||
|
||||
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||
if 'trim_end_ms' not in columns:
|
||||
print("Migrating story_items: adding trim_end_ms column")
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE story_items ADD COLUMN trim_end_ms INTEGER NOT NULL DEFAULT 0"))
|
||||
conn.commit()
|
||||
print("Added trim_end_ms column to story_items")
|
||||
|
||||
# Migration: Add avatar_path to profiles table
|
||||
if 'profiles' in inspector.get_table_names():
|
||||
columns = {col['name'] for col in inspector.get_columns('profiles')}
|
||||
if 'avatar_path' not in columns:
|
||||
print("Migrating profiles: adding avatar_path column")
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE profiles ADD COLUMN avatar_path VARCHAR"))
|
||||
conn.commit()
|
||||
print("Added avatar_path column to profiles")
|
||||
|
||||
|
||||
def get_db():
|
||||
|
||||
@@ -75,6 +75,16 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||
# Check if profile has avatar
|
||||
has_avatar = False
|
||||
if profile.avatar_path:
|
||||
avatar_path = Path(profile.avatar_path)
|
||||
if avatar_path.exists():
|
||||
has_avatar = True
|
||||
# Add avatar to ZIP root with original extension
|
||||
avatar_ext = avatar_path.suffix
|
||||
zip_file.write(avatar_path, f"avatar{avatar_ext}")
|
||||
|
||||
# Create manifest.json
|
||||
manifest = {
|
||||
"version": "1.0",
|
||||
@@ -82,30 +92,31 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
|
||||
"name": profile.name,
|
||||
"description": profile.description,
|
||||
"language": profile.language,
|
||||
}
|
||||
},
|
||||
"has_avatar": has_avatar,
|
||||
}
|
||||
zip_file.writestr("manifest.json", json.dumps(manifest, indent=2))
|
||||
|
||||
|
||||
# Create samples.json mapping
|
||||
samples_data = {}
|
||||
profile_dir = _get_profiles_dir() / profile_id
|
||||
|
||||
|
||||
for sample in samples:
|
||||
# Get filename from audio_path (should be {sample_id}.wav)
|
||||
audio_path = Path(sample.audio_path)
|
||||
filename = audio_path.name
|
||||
|
||||
|
||||
# Read audio file
|
||||
if not audio_path.exists():
|
||||
raise ValueError(f"Audio file not found: {audio_path}")
|
||||
|
||||
|
||||
# Add to samples directory in ZIP
|
||||
zip_path = f"samples/{filename}"
|
||||
zip_file.write(audio_path, zip_path)
|
||||
|
||||
|
||||
# Map filename to reference text
|
||||
samples_data[filename] = sample.reference_text
|
||||
|
||||
|
||||
zip_file.writestr("samples.json", json.dumps(samples_data, indent=2))
|
||||
|
||||
zip_buffer.seek(0)
|
||||
@@ -168,11 +179,31 @@ async def import_profile_from_zip(file_bytes: bytes, db: Session) -> VoiceProfil
|
||||
)
|
||||
|
||||
profile = await create_profile(profile_create, db)
|
||||
|
||||
|
||||
# Extract and add samples
|
||||
profile_dir = _get_profiles_dir() / profile.id
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# Handle avatar if present
|
||||
avatar_files = [f for f in namelist if f.startswith("avatar.")]
|
||||
if avatar_files:
|
||||
try:
|
||||
avatar_file = avatar_files[0]
|
||||
# Extract to temporary file
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=Path(avatar_file).suffix, delete=False) as tmp:
|
||||
tmp.write(zip_file.read(avatar_file))
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
from .profiles import upload_avatar
|
||||
await upload_avatar(profile.id, tmp_path, db)
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
except Exception as e:
|
||||
# Avatar import is optional - continue even if it fails
|
||||
pass
|
||||
|
||||
for filename, reference_text in samples_data.items():
|
||||
# Validate filename
|
||||
if not filename.endswith('.wav'):
|
||||
|
||||
+1552
-108
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user